Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
commit
9ea0eae53c
19 files changed
+545
-55
No files matched your search
@@ -52,10 +52,16 @@ class CacheStore {
|
||||
Future<Directory> _directory() {
|
||||
final name = directoryName(SessionManager().activeAccount?.namespace);
|
||||
return _dirs[name] ??= () async {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${base.path}/$name');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
try {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${base.path}/$name');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
} on Object {
|
||||
// Not memoized (disk full at launch): the next call tries again.
|
||||
unawaited(_dirs.remove(name));
|
||||
rethrow;
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,61 @@ AppException? _dioToAppException(DioException error) {
|
||||
}
|
||||
}
|
||||
|
||||
final RegExp _davException = RegExp(
|
||||
r'<s:exception>\s*([^<]*?)\s*</s:exception>',
|
||||
);
|
||||
final RegExp _davMessage = RegExp(r'<s:message>\s*([^<]*?)\s*</s:message>');
|
||||
|
||||
/// The Sabre DAV error body of a failed WebDAV request: the exception class
|
||||
/// (without its `Sabre\DAV\Exception\` namespace) and the server's message.
|
||||
/// The class says what actually went wrong where the status is ambiguous —
|
||||
/// MKCOL on an existing folder is a plain 405.
|
||||
({String exception, String message})? parseDavError(String body) {
|
||||
final exception = _davException.firstMatch(body)?.group(1);
|
||||
if (exception == null || exception.isEmpty) return null;
|
||||
return (
|
||||
exception: exception.split('\\').last,
|
||||
message: _davMessage.firstMatch(body)?.group(1) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
AppException? _davToAppException(
|
||||
int status,
|
||||
({String exception, String message}) dav,
|
||||
String detail,
|
||||
) {
|
||||
final userMessage = switch (dav.exception) {
|
||||
'MethodNotAllowed' when status == 405 =>
|
||||
'Ein Ordner oder eine Datei mit diesem Namen existiert bereits.',
|
||||
'PreconditionFailed' =>
|
||||
'Am Zielort existiert bereits ein Eintrag mit diesem Namen.',
|
||||
'Conflict' => 'Der übergeordnete Ordner existiert nicht mehr.',
|
||||
'Locked' =>
|
||||
'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.',
|
||||
'InsufficientStorage' =>
|
||||
'Der verfügbare Speicherplatz reicht dafür nicht aus.',
|
||||
_ => null,
|
||||
};
|
||||
if (userMessage == null) return null;
|
||||
return ServerException(
|
||||
statusCode: status,
|
||||
userMessage: userMessage,
|
||||
technicalDetails: detail,
|
||||
allowRetry: dav.exception == 'Locked',
|
||||
);
|
||||
}
|
||||
|
||||
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
|
||||
/// Its toString() dumps every response header, so the details keep only the
|
||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||
final status = error.statusCode;
|
||||
final dav = parseDavError(error.body);
|
||||
if (dav != null) {
|
||||
final detail = 'HTTP $status ${dav.exception}: ${dav.message}';
|
||||
return _davToAppException(status, dav, detail) ??
|
||||
exceptionForStatus(status, detail);
|
||||
}
|
||||
final preview = previewBody(error.body);
|
||||
final detail = preview.isEmpty
|
||||
? 'HTTP $status'
|
||||
|
||||
@@ -7,6 +7,7 @@ class ServerException extends AppException {
|
||||
required this.statusCode,
|
||||
String? userMessage,
|
||||
super.technicalDetails,
|
||||
super.allowRetry,
|
||||
}) : super(
|
||||
// Status code intentionally not in the user message — it lives in
|
||||
// technicalDetails behind "Details anzeigen".
|
||||
@@ -14,6 +15,5 @@ class ServerException extends AppException {
|
||||
userMessage ??
|
||||
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
|
||||
'Bitte versuche es in einem Moment erneut.',
|
||||
allowRetry: true,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../../storage/keychain_write.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
|
||||
/// `first_unlock` accessibility so the token can be read during background
|
||||
@@ -52,9 +53,9 @@ class MarianumConnectTokenStorage {
|
||||
required String tokenId,
|
||||
required DateTime? expiresAt,
|
||||
}) async {
|
||||
await _storage.write(key: _tokenKey, value: token);
|
||||
await _storage.write(key: _tokenIdKey, value: tokenId);
|
||||
await _storage.write(
|
||||
await _storage.writeRetrying(key: _tokenKey, value: token);
|
||||
await _storage.writeRetrying(key: _tokenIdKey, value: tokenId);
|
||||
await _storage.writeRetrying(
|
||||
key: _expiresAtKey,
|
||||
value: expiresAt?.toIso8601String() ?? '',
|
||||
);
|
||||
@@ -75,7 +76,7 @@ class MarianumConnectTokenStorage {
|
||||
if (value == null) {
|
||||
await _storage.delete(key: key);
|
||||
} else {
|
||||
await _storage.write(key: key, value: value);
|
||||
await _storage.writeRetrying(key: key, value: value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../../../storage/keychain_write.dart';
|
||||
import '../../../../utils/random_id.dart';
|
||||
|
||||
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
||||
@@ -22,7 +23,7 @@ class TelemetryDeviceId {
|
||||
return existing;
|
||||
}
|
||||
final generated = randomHexId();
|
||||
await _storage.write(key: _key, value: generated);
|
||||
await _storage.writeRetrying(key: _key, value: generated);
|
||||
_cached = generated;
|
||||
return generated;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ class NotificationService {
|
||||
Future<void> initializeNotifications() async {
|
||||
// Dedicated monochrome status-bar icon: launcher mipmaps are unusable as
|
||||
// small icons because Android renders only their alpha silhouette (a
|
||||
// solid circle in the status bar).
|
||||
// solid circle in the status bar). Bare resource name: the plugin resolves
|
||||
// it via getIdentifier(name, "drawable", ...), and not every ROM strips a
|
||||
// leading "@drawable/" there (reported as invalid_icon on some devices).
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@drawable/ic_stat_notification',
|
||||
'ic_stat_notification',
|
||||
);
|
||||
|
||||
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:localstore/localstore.dart';
|
||||
|
||||
/// Maximum messages kept per chat thread — enough for a messenger-style
|
||||
@@ -190,8 +191,15 @@ class ChatThreadStore {
|
||||
DocumentRef _doc(String chatToken) =>
|
||||
_db.collection(_collection).doc(docIdForToken(chatToken));
|
||||
|
||||
Future<void> _write(String chatToken, List<ThreadMessage> messages) =>
|
||||
_doc(chatToken).set({
|
||||
/// Best effort: a full disk must not keep the notification from being
|
||||
/// shown; the thread just restarts from the next push.
|
||||
Future<void> _write(String chatToken, List<ThreadMessage> messages) async {
|
||||
try {
|
||||
await _doc(chatToken).set({
|
||||
'messages': [for (final m in messages) m.toJson()],
|
||||
});
|
||||
} on Object catch (e) {
|
||||
debugPrint('ChatThreadStore.write($chatToken) failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:localstore/localstore.dart';
|
||||
|
||||
/// A rendered notification's bookkeeping, keyed by the Nextcloud notification
|
||||
@@ -40,8 +41,15 @@ class NidStore {
|
||||
|
||||
NidStore({Localstore? db}) : _db = db ?? Localstore.instance;
|
||||
|
||||
Future<void> put(NidEntry entry) =>
|
||||
_db.collection(_collection).doc('${entry.nid}').set(entry.toJson());
|
||||
/// Best effort: a full disk must not keep the notification itself from
|
||||
/// being shown — only a later delete-push would then miss the mapping.
|
||||
Future<void> put(NidEntry entry) async {
|
||||
try {
|
||||
await _db.collection(_collection).doc('${entry.nid}').set(entry.toJson());
|
||||
} on Object catch (e) {
|
||||
debugPrint('NidStore.put(${entry.nid}) failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<NidEntry?> get(int nid) async {
|
||||
final data = await _db.collection(_collection).doc('$nid').get();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../storage/keychain_write.dart';
|
||||
|
||||
/// Keychain access group shared between the Runner and the (Phase 3) iOS
|
||||
/// Notification Service Extension so the NSE can read the RSA private key,
|
||||
@@ -48,7 +49,9 @@ class PushSecureStorage implements FlutterSecureStorageLike {
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) =>
|
||||
pushSecureStorage.write(key: key, value: value);
|
||||
value == null
|
||||
? pushSecureStorage.delete(key: key)
|
||||
: pushSecureStorage.writeRetrying(key: key, value: value);
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../push/push_secure_storage.dart';
|
||||
import '../storage/keychain_write.dart';
|
||||
import '../utils/exponential_backoff.dart';
|
||||
import '../utils/random_id.dart';
|
||||
import 'account_codec.dart';
|
||||
@@ -165,7 +166,7 @@ class SessionManager {
|
||||
...sessionVaultFields(session),
|
||||
...await _tokenStorage.readAll(),
|
||||
};
|
||||
await _secureStorage.write(
|
||||
await _secureStorage.writeRetrying(
|
||||
key: _vaultKey(entry.id),
|
||||
value: encodeVault(fields),
|
||||
);
|
||||
@@ -209,7 +210,7 @@ class SessionManager {
|
||||
|
||||
Future<void> _saveIndex(AccountIndex index) async {
|
||||
accounts.value = index;
|
||||
await _secureStorage.write(key: _indexKey, value: index.encode());
|
||||
await _secureStorage.writeRetrying(key: _indexKey, value: index.encode());
|
||||
}
|
||||
|
||||
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
||||
@@ -234,7 +235,10 @@ class SessionManager {
|
||||
await setAppPassword(appPassword);
|
||||
await clearAppPasswordTalk();
|
||||
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
|
||||
await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true');
|
||||
await _secureStorage.writeRetrying(
|
||||
key: SessionKeys.loginFlow,
|
||||
value: 'true',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAppPasswordTalk(String appPassword) async {
|
||||
@@ -258,7 +262,7 @@ class SessionManager {
|
||||
|
||||
Future<void> _writeSecret(String key, String? value) => value == null
|
||||
? _secureStorage.delete(key: key)
|
||||
: _secureStorage.write(key: key, value: value);
|
||||
: _secureStorage.writeRetrying(key: key, value: value);
|
||||
|
||||
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
||||
// Notification Service Extension can authenticate Nextcloud calls too. That
|
||||
@@ -269,7 +273,7 @@ class SessionManager {
|
||||
if (value == null) {
|
||||
await pushSecureStorage.delete(key: key);
|
||||
} else {
|
||||
await pushSecureStorage.write(key: key, value: value);
|
||||
await pushSecureStorage.writeRetrying(key: key, value: value);
|
||||
}
|
||||
} on Object {
|
||||
// ignore — see above
|
||||
@@ -362,7 +366,10 @@ class SessionManager {
|
||||
namespace: index.accounts.isEmpty ? '' : null,
|
||||
now: _now(),
|
||||
);
|
||||
await _secureStorage.write(key: _indexKey, value: index.encode());
|
||||
await _secureStorage.writeRetrying(
|
||||
key: _indexKey,
|
||||
value: index.encode(),
|
||||
);
|
||||
}
|
||||
accounts.value = index;
|
||||
return;
|
||||
@@ -385,11 +392,11 @@ class SessionManager {
|
||||
final hasSecure =
|
||||
(await _secureStorage.read(key: SessionKeys.username)) != null;
|
||||
if (!hasSecure) {
|
||||
await _secureStorage.write(
|
||||
await _secureStorage.writeRetrying(
|
||||
key: SessionKeys.username,
|
||||
value: legacyUsername,
|
||||
);
|
||||
await _secureStorage.write(
|
||||
await _secureStorage.writeRetrying(
|
||||
key: SessionKeys.password,
|
||||
value: legacyPassword,
|
||||
);
|
||||
@@ -408,7 +415,7 @@ class SessionManager {
|
||||
if (value == null) continue;
|
||||
// Same account+service: the legacy item has to go before the re-add.
|
||||
await _legacySecureStorage.delete(key: field);
|
||||
await _secureStorage.write(key: field, value: value);
|
||||
await _secureStorage.writeRetrying(key: field, value: value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// `errSecDuplicateItem`: the iOS keychain refused to add an item that is
|
||||
/// already there.
|
||||
const int kErrSecDuplicateItem = -25299;
|
||||
|
||||
/// Whether [error] is the iOS keychain's "item already exists" failure.
|
||||
/// flutter_secure_storage surfaces it as a PlatformException whose details
|
||||
/// (and message) carry the OSStatus.
|
||||
bool isKeychainDuplicateItem(Object error) {
|
||||
if (error is! PlatformException) return false;
|
||||
if (error.details == kErrSecDuplicateItem) return true;
|
||||
return error.message?.contains('$kErrSecDuplicateItem') ?? false;
|
||||
}
|
||||
|
||||
extension KeychainWrite on FlutterSecureStorage {
|
||||
/// [write] that survives `errSecDuplicateItem`. The plugin's iOS write is
|
||||
/// check-then-add/update and not atomic: a concurrent write to the same key
|
||||
/// — or an existence check that itself failed (seen with the disk full) —
|
||||
/// ends in SecItemAdd on an existing item. The second attempt finds the
|
||||
/// item and updates it.
|
||||
Future<void> writeRetrying({
|
||||
required String key,
|
||||
required String value,
|
||||
}) async {
|
||||
try {
|
||||
await write(key: key, value: value);
|
||||
} on PlatformException catch (e) {
|
||||
if (!isKeychainDuplicateItem(e)) rethrow;
|
||||
await write(key: key, value: value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,18 +39,24 @@ class AvatarDiskCache {
|
||||
|
||||
/// Kicks off directory resolution so [readSync] can hit on the very first
|
||||
/// avatar of a session. Fire-and-forget from app start; safe to call twice.
|
||||
void warmUp() => unawaited(_directory());
|
||||
void warmUp() => unawaited(_directory().then((_) {}, onError: (Object _) {}));
|
||||
|
||||
Future<Directory> _directory() {
|
||||
return _dirFuture ??= _resolveDirectory();
|
||||
}
|
||||
|
||||
Future<Directory> _resolveDirectory() async {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${base.path}/$_subDirectory');
|
||||
await dir.create(recursive: true);
|
||||
_dirPath = dir.path;
|
||||
return dir;
|
||||
try {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${base.path}/$_subDirectory');
|
||||
await dir.create(recursive: true);
|
||||
_dirPath = dir.path;
|
||||
return dir;
|
||||
} on Object {
|
||||
// Not memoized (disk full at launch): the next call tries again.
|
||||
_dirFuture = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// File-safe, prefix-evictable name. The subject id is hex-encoded so it can
|
||||
|
||||
@@ -53,13 +53,21 @@ class FilePick {
|
||||
return result?.files.map((e) => e.path).nonNulls.toList();
|
||||
}
|
||||
|
||||
static bool _inFlight = false;
|
||||
|
||||
/// A second tap while the native picker is already open (double tap, or a
|
||||
/// tap on another source tile) would make the plugin throw instead of
|
||||
/// opening a second picker; it is treated as a cancel.
|
||||
static Future<T?> _guarded<T>(
|
||||
BuildContext context,
|
||||
Future<T?> Function() pick,
|
||||
) async {
|
||||
if (_inFlight) return null;
|
||||
_inFlight = true;
|
||||
try {
|
||||
return await pick();
|
||||
} on PlatformException catch (e) {
|
||||
if (_isAlreadyActive(e)) return null;
|
||||
final denied = PermissionException.fromPlatform(e);
|
||||
if (!context.mounted) return null;
|
||||
if (denied != null) {
|
||||
@@ -71,9 +79,15 @@ class FilePick {
|
||||
} catch (e) {
|
||||
if (context.mounted) _showErrorDialog(context, e);
|
||||
return null;
|
||||
} finally {
|
||||
_inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Android reports `already_active`, iOS `multiple_request`.
|
||||
static bool _isAlreadyActive(PlatformException e) =>
|
||||
e.code == 'already_active' || e.code == 'multiple_request';
|
||||
|
||||
static void _showPermissionDialog(
|
||||
BuildContext context,
|
||||
PermissionException denied,
|
||||
|
||||
@@ -42,7 +42,7 @@ class PmImageView extends StatelessWidget {
|
||||
final available = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.sizeOf(context).width;
|
||||
final targetWidth = _resolveWidth(node.width, available);
|
||||
final targetWidth = resolvePmImageWidth(node.width, available);
|
||||
|
||||
Widget image = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
|
||||
@@ -121,16 +121,20 @@ class PmImageView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
double? _resolveWidth(String? width, double available) {
|
||||
if (width == null) return null;
|
||||
final length = parsePmCssLength(width);
|
||||
if (length == null) return null;
|
||||
final value = length.value;
|
||||
final resolved = switch (length.unit) {
|
||||
'%' => available * value / 100,
|
||||
'em' || 'rem' => value * 16,
|
||||
_ => value,
|
||||
};
|
||||
return resolved.clamp(1, available);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a CSS `width` attribute against [available] pixels, or `null` when
|
||||
/// the image should simply fill what it gets. Layout may hand out zero width
|
||||
/// mid-animation, which would otherwise invert the clamp bounds.
|
||||
double? resolvePmImageWidth(String? width, double available) {
|
||||
if (width == null || available < 1) return null;
|
||||
final length = parsePmCssLength(width);
|
||||
if (length == null) return null;
|
||||
final value = length.value;
|
||||
final resolved = switch (length.unit) {
|
||||
'%' => available * value / 100,
|
||||
'em' || 'rem' => value * 16,
|
||||
_ => value,
|
||||
};
|
||||
return resolved.clamp(1, available);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_progress_indicator.dart';
|
||||
@@ -23,8 +25,28 @@ import 'app_progress_indicator.dart';
|
||||
/// wraps every tab screen in an animated `Transform.translate` and builds a tab
|
||||
/// lazily on first activation, which is how the ticker's in-place PDF pages hit
|
||||
/// the same window with no route animation in sight.
|
||||
///
|
||||
/// Neither covers a subtree that is *moved* (GlobalKey) under a fresh
|
||||
/// transform while already settled — Android's predictive back does exactly
|
||||
/// that: on gesture start it swaps the route's transition widgets, and the
|
||||
/// route content (`_subtreeKey`) lands under a brand-new `Transform.scale`
|
||||
/// with no animation status change. Flutter rebuilds every stateful widget in
|
||||
/// a moved subtree, so the gate re-checks its ancestors on each build and
|
||||
/// re-gates while any of them has not been laid out yet.
|
||||
///
|
||||
/// The one thing the gate must *not* do is unmount the subtree while a pointer
|
||||
/// is down on it: pointer events keep going to the hit-test path captured at
|
||||
/// pointer-down, and `SfPdfViewer` dereferences its `context` in the handler
|
||||
/// after dispose. iOS' back swipe hits both the edge detector and the page, so
|
||||
/// the pop starts with the finger still on the viewer — the subtree is held
|
||||
/// until the pointer lifts. An unlaid-out ancestor still wins, since that
|
||||
/// crash is certain in the very same frame.
|
||||
class RouteTransitionGate extends StatefulWidget {
|
||||
const RouteTransitionGate({super.key, required this.builder, this.placeholder});
|
||||
const RouteTransitionGate({
|
||||
super.key,
|
||||
required this.builder,
|
||||
this.placeholder,
|
||||
});
|
||||
|
||||
final WidgetBuilder builder;
|
||||
|
||||
@@ -45,6 +67,9 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
||||
bool _settled = false;
|
||||
bool _settleScheduled = false;
|
||||
|
||||
int _activePointers = 0;
|
||||
bool _childMounted = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -102,11 +127,58 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_transitioning) {
|
||||
if (_settled) return widget.builder(context);
|
||||
_scheduleSettle();
|
||||
void deactivate() {
|
||||
// About to be moved (or disposed); whatever we end up under must earn
|
||||
// the settled state again.
|
||||
_settled = false;
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
static bool _hasUnlaidOutAncestor(BuildContext context) {
|
||||
var found = false;
|
||||
context.visitAncestorElements((element) {
|
||||
final renderObject = element is RenderObjectElement
|
||||
? element.renderObject
|
||||
: null;
|
||||
if (renderObject is RenderBox && !renderObject.hasSize) {
|
||||
found = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
void _onPointerUp(PointerEvent _) {
|
||||
_activePointers = math.max(0, _activePointers - 1);
|
||||
if (_activePointers > 0 || !mounted) return;
|
||||
// Held through a transition that has since finished: the subtree is
|
||||
// mounted and laid out, nothing to re-earn. Otherwise let go now.
|
||||
if (_transitioning) {
|
||||
setState(() {});
|
||||
} else {
|
||||
_settled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blocked = _hasUnlaidOutAncestor(context);
|
||||
if (blocked) _settled = false;
|
||||
final hold = _childMounted && _activePointers > 0 && !blocked;
|
||||
if (hold && !_transitioning) _settled = true;
|
||||
if ((_settled && !_transitioning) || hold) {
|
||||
_childMounted = true;
|
||||
return Listener(
|
||||
onPointerDown: (_) => _activePointers++,
|
||||
onPointerUp: _onPointerUp,
|
||||
onPointerCancel: _onPointerUp,
|
||||
child: widget.builder(context),
|
||||
);
|
||||
}
|
||||
_childMounted = false;
|
||||
_activePointers = 0;
|
||||
if (!_transitioning) _scheduleSettle();
|
||||
return widget.placeholder ??
|
||||
const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import 'package:marianum_mobile/api/errors/parse_exception.dart';
|
||||
import 'package:marianum_mobile/api/errors/permission_exception.dart';
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
(String, bool) errorUserMessageAndRetry(Object error) =>
|
||||
(errorToUserMessage(error), errorAllowsRetry(error));
|
||||
|
||||
void main() {
|
||||
group('errorToUserMessage', () {
|
||||
test('null falls back to the default message', () {
|
||||
@@ -165,6 +168,96 @@ void main() {
|
||||
expect(errorToUserMessage(ex), contains('Zu viele Anfragen'));
|
||||
expect(errorAllowsRetry(ex), isTrue);
|
||||
});
|
||||
|
||||
String davBody(String exception, String message) =>
|
||||
'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||
'<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">\n'
|
||||
' <s:exception>Sabre\\DAV\\Exception\\$exception</s:exception>\n'
|
||||
' <s:message>$message</s:message>\n'
|
||||
'</d:error>';
|
||||
|
||||
test('parseDavError extracts class and message', () {
|
||||
final dav = parseDavError(
|
||||
davBody(
|
||||
'MethodNotAllowed',
|
||||
'The resource you tried to create already exists',
|
||||
),
|
||||
);
|
||||
expect(dav?.exception, 'MethodNotAllowed');
|
||||
expect(dav?.message, 'The resource you tried to create already exists');
|
||||
expect(parseDavError('<html>nope</html>'), isNull);
|
||||
});
|
||||
|
||||
test('MKCOL 405 on an existing folder says so and allows no retry', () {
|
||||
final ex = DynamiteApiException(
|
||||
405,
|
||||
const {},
|
||||
davBody(
|
||||
'MethodNotAllowed',
|
||||
'The resource you tried to create already exists',
|
||||
),
|
||||
);
|
||||
expect(errorToUserMessage(ex), contains('existiert bereits'));
|
||||
expect(
|
||||
errorToTechnicalDetails(ex),
|
||||
'HTTP 405 MethodNotAllowed: The resource you tried to create already exists',
|
||||
);
|
||||
expect(errorAllowsRetry(ex), isFalse);
|
||||
});
|
||||
|
||||
test('other DAV exceptions map to their own messages', () {
|
||||
expect(
|
||||
errorToUserMessage(
|
||||
DynamiteApiException(
|
||||
412,
|
||||
const {},
|
||||
davBody('PreconditionFailed', 'x'),
|
||||
),
|
||||
),
|
||||
contains('Zielort'),
|
||||
);
|
||||
expect(
|
||||
errorToUserMessage(
|
||||
DynamiteApiException(409, const {}, davBody('Conflict', 'x')),
|
||||
),
|
||||
contains('übergeordnete Ordner'),
|
||||
);
|
||||
expect(
|
||||
errorUserMessageAndRetry(
|
||||
DynamiteApiException(423, const {}, davBody('Locked', 'x')),
|
||||
),
|
||||
(
|
||||
'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.',
|
||||
true,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
errorToUserMessage(
|
||||
DynamiteApiException(
|
||||
507,
|
||||
const {},
|
||||
davBody('InsufficientStorage', 'x'),
|
||||
),
|
||||
),
|
||||
contains('Speicherplatz'),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'unknown DAV exceptions keep the status mapping but name the class',
|
||||
() {
|
||||
final ex = DynamiteApiException(
|
||||
404,
|
||||
const {},
|
||||
davBody('NotFound', 'File not found'),
|
||||
);
|
||||
expect(errorToUserMessage(ex), const NotFoundException().userMessage);
|
||||
expect(
|
||||
errorToTechnicalDetails(ex),
|
||||
'HTTP 404 NotFound: File not found',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('errorToTechnicalDetails', () {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/storage/keychain_write.dart';
|
||||
|
||||
void main() {
|
||||
group('isKeychainDuplicateItem', () {
|
||||
test('recognises the OSStatus in the details', () {
|
||||
final e = PlatformException(
|
||||
code: 'Unexpected security result code',
|
||||
message:
|
||||
'Code: -25299, Message: The specified item already exists in the keychain.',
|
||||
details: -25299,
|
||||
);
|
||||
expect(isKeychainDuplicateItem(e), isTrue);
|
||||
});
|
||||
|
||||
test('falls back to the message when details are missing', () {
|
||||
final e = PlatformException(
|
||||
code: 'Unexpected security result code',
|
||||
message: 'Code: -25299, Message: duplicate',
|
||||
);
|
||||
expect(isKeychainDuplicateItem(e), isTrue);
|
||||
});
|
||||
|
||||
test('ignores other keychain errors and non-platform errors', () {
|
||||
final e = PlatformException(
|
||||
code: 'Unexpected security result code',
|
||||
message: 'Code: -25308, Message: interaction not allowed',
|
||||
details: -25308,
|
||||
);
|
||||
expect(isKeychainDuplicateItem(e), isFalse);
|
||||
expect(isKeychainDuplicateItem(StateError('x')), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/widget/prosemirror/pm_image_view.dart';
|
||||
|
||||
void main() {
|
||||
group('resolvePmImageWidth', () {
|
||||
test('returns null without a width attribute', () {
|
||||
expect(resolvePmImageWidth(null, 300), isNull);
|
||||
});
|
||||
|
||||
test('returns null for unparseable widths', () {
|
||||
expect(resolvePmImageWidth('auto', 300), isNull);
|
||||
});
|
||||
|
||||
test('resolves px, %, em and rem', () {
|
||||
expect(resolvePmImageWidth('120px', 300), 120);
|
||||
expect(resolvePmImageWidth('50%', 300), 150);
|
||||
expect(resolvePmImageWidth('2em', 300), 32);
|
||||
expect(resolvePmImageWidth('2rem', 300), 32);
|
||||
});
|
||||
|
||||
test('caps at the available width and floors at one pixel', () {
|
||||
expect(resolvePmImageWidth('900px', 300), 300);
|
||||
expect(resolvePmImageWidth('0px', 300), 1);
|
||||
});
|
||||
|
||||
test('survives zero or sub-pixel available width', () {
|
||||
expect(resolvePmImageWidth('50%', 0), isNull);
|
||||
expect(resolvePmImageWidth('120px', 0.5), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,12 @@ void main() {
|
||||
Widget gatedPage() => Scaffold(
|
||||
body: RouteTransitionGate(
|
||||
placeholder: const SizedBox(key: placeholder),
|
||||
builder: (_) => const SizedBox(key: gated),
|
||||
// Hit-testable like the real viewer, so pointer tracking sees it.
|
||||
builder: (_) => const ColoredBox(
|
||||
key: gated,
|
||||
color: Color(0x00000000),
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,7 +34,9 @@ void main() {
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
navigator(
|
||||
tester,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
@@ -45,7 +52,9 @@ void main() {
|
||||
testWidgets('swaps back to placeholder while the route pops', (tester) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
navigator(
|
||||
tester,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
@@ -63,13 +72,17 @@ void main() {
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
navigator(
|
||||
tester,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
||||
navigator(
|
||||
tester,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
@@ -84,6 +97,86 @@ void main() {
|
||||
expect(find.byKey(placeholder), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('re-gates when the settled subtree moves under a fresh Transform', (
|
||||
tester,
|
||||
) async {
|
||||
// Android's predictive back moves the route content (GlobalKey) under a
|
||||
// brand-new Transform.scale without any animation status change. The
|
||||
// padding changes the constraints so the moved subtree is laid out nested
|
||||
// under the transform's first layout, which is when `localToGlobal` throws.
|
||||
final subtree = GlobalKey();
|
||||
var wrapped = false;
|
||||
late StateSetter rebuild;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
rebuild = setState;
|
||||
final child = KeyedSubtree(
|
||||
key: subtree,
|
||||
child: Scaffold(
|
||||
body: RouteTransitionGate(
|
||||
placeholder: const SizedBox(key: placeholder),
|
||||
builder: (_) => const _LocalToGlobalProbe(key: gated),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!wrapped) return child;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: Transform.scale(scale: 1, child: child),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
|
||||
rebuild(() => wrapped = true);
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byKey(placeholder), findsOneWidget);
|
||||
|
||||
await tester.pump();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('keeps the child mounted while a back swipe drives the pop', (
|
||||
tester,
|
||||
) async {
|
||||
// iOS back swipe: the edge detector is translucent, so the finger that
|
||||
// drives the pop animation is also down on the viewer. Unmounting it now
|
||||
// would route the next pointer move into a disposed State. (A programmatic
|
||||
// pop is harmless — the Navigator cancels active pointers first.)
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(
|
||||
tester,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
|
||||
final gesture = await tester.startGesture(const Offset(5, 300));
|
||||
await gesture.moveBy(const Offset(60, 0));
|
||||
await tester.pump();
|
||||
await gesture.moveBy(const Offset(60, 0));
|
||||
await tester.pump();
|
||||
// Pop animation is running (status reverse), finger still down.
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
expect(find.byKey(placeholder), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
expect(find.byKey(gated), findsNothing);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
|
||||
|
||||
testWidgets('holds the child back for one frame without a route animation', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -99,3 +192,25 @@ void main() {
|
||||
expect(find.byKey(placeholder), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
/// Mimics SfPdfViewer: a stateful widget whose LayoutBuilder resolves a
|
||||
/// child's global position during layout.
|
||||
class _LocalToGlobalProbe extends StatefulWidget {
|
||||
const _LocalToGlobalProbe({super.key});
|
||||
|
||||
@override
|
||||
State<_LocalToGlobalProbe> createState() => _LocalToGlobalProbeState();
|
||||
}
|
||||
|
||||
class _LocalToGlobalProbeState extends State<_LocalToGlobalProbe> {
|
||||
final _viewport = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final box = _viewport.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box != null && box.hasSize) box.localToGlobal(Offset.zero);
|
||||
return SizedBox.expand(key: _viewport);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user