Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
MineTec committed 2026-10-19 09:49:31 +02:00
commit 9ea0eae53c
19 files changed
+545 -55

No files matched your search

+10 -4
View File
@@ -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;
}
}();
}
+50
View File
@@ -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'
+1 -1
View File
@@ -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;
}
+4 -2
View File
@@ -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
+10 -2
View File
@@ -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
View File
@@ -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();
+4 -1
View File
@@ -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}) =>
+16 -9
View File
@@ -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);
}
}
}
+34
View File
@@ -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);
}
}
}
+12 -6
View File
@@ -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
+14
View File
@@ -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,
+17 -13
View File
@@ -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);
}
+77 -5
View File
@@ -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());
}
+93
View File
@@ -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', () {
+35
View File
@@ -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);
});
});
}
+120 -5
View File
@@ -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);
},
);
}