fixed client-reported crashes in the pdf viewer, image picker and prosemirror images, mapped webdav errors to user messages and hardened keychain, cache and push stores against io failures
This commit is contained in:
1 parent
c5592f117b
commit
23e4510bf8
19 files changed
+545
-55
No files matched your search
@@ -52,10 +52,16 @@ class CacheStore {
|
|||||||
Future<Directory> _directory() {
|
Future<Directory> _directory() {
|
||||||
final name = directoryName(SessionManager().activeAccount?.namespace);
|
final name = directoryName(SessionManager().activeAccount?.namespace);
|
||||||
return _dirs[name] ??= () async {
|
return _dirs[name] ??= () async {
|
||||||
final base = await getApplicationCacheDirectory();
|
try {
|
||||||
final dir = Directory('${base.path}/$name');
|
final base = await getApplicationCacheDirectory();
|
||||||
await dir.create(recursive: true);
|
final dir = Directory('${base.path}/$name');
|
||||||
return dir;
|
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.
|
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
|
||||||
/// Its toString() dumps every response header, so the details keep only the
|
/// 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).
|
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||||
final status = error.statusCode;
|
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 preview = previewBody(error.body);
|
||||||
final detail = preview.isEmpty
|
final detail = preview.isEmpty
|
||||||
? 'HTTP $status'
|
? 'HTTP $status'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ServerException extends AppException {
|
|||||||
required this.statusCode,
|
required this.statusCode,
|
||||||
String? userMessage,
|
String? userMessage,
|
||||||
super.technicalDetails,
|
super.technicalDetails,
|
||||||
|
super.allowRetry,
|
||||||
}) : super(
|
}) : super(
|
||||||
// Status code intentionally not in the user message — it lives in
|
// Status code intentionally not in the user message — it lives in
|
||||||
// technicalDetails behind "Details anzeigen".
|
// technicalDetails behind "Details anzeigen".
|
||||||
@@ -14,6 +15,5 @@ class ServerException extends AppException {
|
|||||||
userMessage ??
|
userMessage ??
|
||||||
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
|
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
|
||||||
'Bitte versuche es in einem Moment erneut.',
|
'Bitte versuche es in einem Moment erneut.',
|
||||||
allowRetry: true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../../storage/keychain_write.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
import '../../errors/auth_exception.dart';
|
||||||
|
|
||||||
/// `first_unlock` accessibility so the token can be read during background
|
/// `first_unlock` accessibility so the token can be read during background
|
||||||
@@ -52,9 +53,9 @@ class MarianumConnectTokenStorage {
|
|||||||
required String tokenId,
|
required String tokenId,
|
||||||
required DateTime? expiresAt,
|
required DateTime? expiresAt,
|
||||||
}) async {
|
}) async {
|
||||||
await _storage.write(key: _tokenKey, value: token);
|
await _storage.writeRetrying(key: _tokenKey, value: token);
|
||||||
await _storage.write(key: _tokenIdKey, value: tokenId);
|
await _storage.writeRetrying(key: _tokenIdKey, value: tokenId);
|
||||||
await _storage.write(
|
await _storage.writeRetrying(
|
||||||
key: _expiresAtKey,
|
key: _expiresAtKey,
|
||||||
value: expiresAt?.toIso8601String() ?? '',
|
value: expiresAt?.toIso8601String() ?? '',
|
||||||
);
|
);
|
||||||
@@ -75,7 +76,7 @@ class MarianumConnectTokenStorage {
|
|||||||
if (value == null) {
|
if (value == null) {
|
||||||
await _storage.delete(key: key);
|
await _storage.delete(key: key);
|
||||||
} else {
|
} 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 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../../../storage/keychain_write.dart';
|
||||||
import '../../../../utils/random_id.dart';
|
import '../../../../utils/random_id.dart';
|
||||||
|
|
||||||
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
||||||
@@ -22,7 +23,7 @@ class TelemetryDeviceId {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
final generated = randomHexId();
|
final generated = randomHexId();
|
||||||
await _storage.write(key: _key, value: generated);
|
await _storage.writeRetrying(key: _key, value: generated);
|
||||||
_cached = generated;
|
_cached = generated;
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ class NotificationService {
|
|||||||
Future<void> initializeNotifications() async {
|
Future<void> initializeNotifications() async {
|
||||||
// Dedicated monochrome status-bar icon: launcher mipmaps are unusable as
|
// Dedicated monochrome status-bar icon: launcher mipmaps are unusable as
|
||||||
// small icons because Android renders only their alpha silhouette (a
|
// 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(
|
const androidSettings = AndroidInitializationSettings(
|
||||||
'@drawable/ic_stat_notification',
|
'ic_stat_notification',
|
||||||
);
|
);
|
||||||
|
|
||||||
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:localstore/localstore.dart';
|
import 'package:localstore/localstore.dart';
|
||||||
|
|
||||||
/// Maximum messages kept per chat thread — enough for a messenger-style
|
/// Maximum messages kept per chat thread — enough for a messenger-style
|
||||||
@@ -190,8 +191,15 @@ class ChatThreadStore {
|
|||||||
DocumentRef _doc(String chatToken) =>
|
DocumentRef _doc(String chatToken) =>
|
||||||
_db.collection(_collection).doc(docIdForToken(chatToken));
|
_db.collection(_collection).doc(docIdForToken(chatToken));
|
||||||
|
|
||||||
Future<void> _write(String chatToken, List<ThreadMessage> messages) =>
|
/// Best effort: a full disk must not keep the notification from being
|
||||||
_doc(chatToken).set({
|
/// 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()],
|
'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';
|
import 'package:localstore/localstore.dart';
|
||||||
|
|
||||||
/// A rendered notification's bookkeeping, keyed by the Nextcloud notification
|
/// A rendered notification's bookkeeping, keyed by the Nextcloud notification
|
||||||
@@ -40,8 +41,15 @@ class NidStore {
|
|||||||
|
|
||||||
NidStore({Localstore? db}) : _db = db ?? Localstore.instance;
|
NidStore({Localstore? db}) : _db = db ?? Localstore.instance;
|
||||||
|
|
||||||
Future<void> put(NidEntry entry) =>
|
/// Best effort: a full disk must not keep the notification itself from
|
||||||
_db.collection(_collection).doc('${entry.nid}').set(entry.toJson());
|
/// 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 {
|
Future<NidEntry?> get(int nid) async {
|
||||||
final data = await _db.collection(_collection).doc('$nid').get();
|
final data = await _db.collection(_collection).doc('$nid').get();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
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
|
/// Keychain access group shared between the Runner and the (Phase 3) iOS
|
||||||
/// Notification Service Extension so the NSE can read the RSA private key,
|
/// Notification Service Extension so the NSE can read the RSA private key,
|
||||||
@@ -48,7 +49,9 @@ class PushSecureStorage implements FlutterSecureStorageLike {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> write({required String key, required String? value}) =>
|
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
|
@override
|
||||||
Future<void> delete({required String key}) =>
|
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 '../api/marianumconnect/auth/token_storage.dart';
|
||||||
import '../push/push_secure_storage.dart';
|
import '../push/push_secure_storage.dart';
|
||||||
|
import '../storage/keychain_write.dart';
|
||||||
import '../utils/exponential_backoff.dart';
|
import '../utils/exponential_backoff.dart';
|
||||||
import '../utils/random_id.dart';
|
import '../utils/random_id.dart';
|
||||||
import 'account_codec.dart';
|
import 'account_codec.dart';
|
||||||
@@ -165,7 +166,7 @@ class SessionManager {
|
|||||||
...sessionVaultFields(session),
|
...sessionVaultFields(session),
|
||||||
...await _tokenStorage.readAll(),
|
...await _tokenStorage.readAll(),
|
||||||
};
|
};
|
||||||
await _secureStorage.write(
|
await _secureStorage.writeRetrying(
|
||||||
key: _vaultKey(entry.id),
|
key: _vaultKey(entry.id),
|
||||||
value: encodeVault(fields),
|
value: encodeVault(fields),
|
||||||
);
|
);
|
||||||
@@ -209,7 +210,7 @@ class SessionManager {
|
|||||||
|
|
||||||
Future<void> _saveIndex(AccountIndex index) async {
|
Future<void> _saveIndex(AccountIndex index) async {
|
||||||
accounts.value = index;
|
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;
|
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
||||||
@@ -234,7 +235,10 @@ class SessionManager {
|
|||||||
await setAppPassword(appPassword);
|
await setAppPassword(appPassword);
|
||||||
await clearAppPasswordTalk();
|
await clearAppPasswordTalk();
|
||||||
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
|
_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 {
|
Future<void> setAppPasswordTalk(String appPassword) async {
|
||||||
@@ -258,7 +262,7 @@ class SessionManager {
|
|||||||
|
|
||||||
Future<void> _writeSecret(String key, String? value) => value == null
|
Future<void> _writeSecret(String key, String? value) => value == null
|
||||||
? _secureStorage.delete(key: key)
|
? _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
|
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
||||||
// Notification Service Extension can authenticate Nextcloud calls too. That
|
// Notification Service Extension can authenticate Nextcloud calls too. That
|
||||||
@@ -269,7 +273,7 @@ class SessionManager {
|
|||||||
if (value == null) {
|
if (value == null) {
|
||||||
await pushSecureStorage.delete(key: key);
|
await pushSecureStorage.delete(key: key);
|
||||||
} else {
|
} else {
|
||||||
await pushSecureStorage.write(key: key, value: value);
|
await pushSecureStorage.writeRetrying(key: key, value: value);
|
||||||
}
|
}
|
||||||
} on Object {
|
} on Object {
|
||||||
// ignore — see above
|
// ignore — see above
|
||||||
@@ -362,7 +366,10 @@ class SessionManager {
|
|||||||
namespace: index.accounts.isEmpty ? '' : null,
|
namespace: index.accounts.isEmpty ? '' : null,
|
||||||
now: _now(),
|
now: _now(),
|
||||||
);
|
);
|
||||||
await _secureStorage.write(key: _indexKey, value: index.encode());
|
await _secureStorage.writeRetrying(
|
||||||
|
key: _indexKey,
|
||||||
|
value: index.encode(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
accounts.value = index;
|
accounts.value = index;
|
||||||
return;
|
return;
|
||||||
@@ -385,11 +392,11 @@ class SessionManager {
|
|||||||
final hasSecure =
|
final hasSecure =
|
||||||
(await _secureStorage.read(key: SessionKeys.username)) != null;
|
(await _secureStorage.read(key: SessionKeys.username)) != null;
|
||||||
if (!hasSecure) {
|
if (!hasSecure) {
|
||||||
await _secureStorage.write(
|
await _secureStorage.writeRetrying(
|
||||||
key: SessionKeys.username,
|
key: SessionKeys.username,
|
||||||
value: legacyUsername,
|
value: legacyUsername,
|
||||||
);
|
);
|
||||||
await _secureStorage.write(
|
await _secureStorage.writeRetrying(
|
||||||
key: SessionKeys.password,
|
key: SessionKeys.password,
|
||||||
value: legacyPassword,
|
value: legacyPassword,
|
||||||
);
|
);
|
||||||
@@ -408,7 +415,7 @@ class SessionManager {
|
|||||||
if (value == null) continue;
|
if (value == null) continue;
|
||||||
// Same account+service: the legacy item has to go before the re-add.
|
// Same account+service: the legacy item has to go before the re-add.
|
||||||
await _legacySecureStorage.delete(key: field);
|
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
|
/// 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.
|
/// 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() {
|
Future<Directory> _directory() {
|
||||||
return _dirFuture ??= _resolveDirectory();
|
return _dirFuture ??= _resolveDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> _resolveDirectory() async {
|
Future<Directory> _resolveDirectory() async {
|
||||||
final base = await getApplicationCacheDirectory();
|
try {
|
||||||
final dir = Directory('${base.path}/$_subDirectory');
|
final base = await getApplicationCacheDirectory();
|
||||||
await dir.create(recursive: true);
|
final dir = Directory('${base.path}/$_subDirectory');
|
||||||
_dirPath = dir.path;
|
await dir.create(recursive: true);
|
||||||
return dir;
|
_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
|
/// 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();
|
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>(
|
static Future<T?> _guarded<T>(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Future<T?> Function() pick,
|
Future<T?> Function() pick,
|
||||||
) async {
|
) async {
|
||||||
|
if (_inFlight) return null;
|
||||||
|
_inFlight = true;
|
||||||
try {
|
try {
|
||||||
return await pick();
|
return await pick();
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
|
if (_isAlreadyActive(e)) return null;
|
||||||
final denied = PermissionException.fromPlatform(e);
|
final denied = PermissionException.fromPlatform(e);
|
||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
if (denied != null) {
|
if (denied != null) {
|
||||||
@@ -71,9 +79,15 @@ class FilePick {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) _showErrorDialog(context, e);
|
if (context.mounted) _showErrorDialog(context, e);
|
||||||
return null;
|
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(
|
static void _showPermissionDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
PermissionException denied,
|
PermissionException denied,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class PmImageView extends StatelessWidget {
|
|||||||
final available = constraints.maxWidth.isFinite
|
final available = constraints.maxWidth.isFinite
|
||||||
? constraints.maxWidth
|
? constraints.maxWidth
|
||||||
: MediaQuery.sizeOf(context).width;
|
: MediaQuery.sizeOf(context).width;
|
||||||
final targetWidth = _resolveWidth(node.width, available);
|
final targetWidth = resolvePmImageWidth(node.width, available);
|
||||||
|
|
||||||
Widget image = ConstrainedBox(
|
Widget image = ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
|
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);
|
/// Resolves a CSS `width` attribute against [available] pixels, or `null` when
|
||||||
if (length == null) return null;
|
/// the image should simply fill what it gets. Layout may hand out zero width
|
||||||
final value = length.value;
|
/// mid-animation, which would otherwise invert the clamp bounds.
|
||||||
final resolved = switch (length.unit) {
|
double? resolvePmImageWidth(String? width, double available) {
|
||||||
'%' => available * value / 100,
|
if (width == null || available < 1) return null;
|
||||||
'em' || 'rem' => value * 16,
|
final length = parsePmCssLength(width);
|
||||||
_ => value,
|
if (length == null) return null;
|
||||||
};
|
final value = length.value;
|
||||||
return resolved.clamp(1, available);
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'app_progress_indicator.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
|
/// 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
|
/// lazily on first activation, which is how the ticker's in-place PDF pages hit
|
||||||
/// the same window with no route animation in sight.
|
/// 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 {
|
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;
|
final WidgetBuilder builder;
|
||||||
|
|
||||||
@@ -45,6 +67,9 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
|||||||
bool _settled = false;
|
bool _settled = false;
|
||||||
bool _settleScheduled = false;
|
bool _settleScheduled = false;
|
||||||
|
|
||||||
|
int _activePointers = 0;
|
||||||
|
bool _childMounted = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -102,11 +127,58 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void deactivate() {
|
||||||
if (!_transitioning) {
|
// About to be moved (or disposed); whatever we end up under must earn
|
||||||
if (_settled) return widget.builder(context);
|
// the settled state again.
|
||||||
_scheduleSettle();
|
_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 ??
|
return widget.placeholder ??
|
||||||
const Center(child: AppProgressIndicator.large());
|
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:marianum_mobile/api/errors/permission_exception.dart';
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
|
(String, bool) errorUserMessageAndRetry(Object error) =>
|
||||||
|
(errorToUserMessage(error), errorAllowsRetry(error));
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('errorToUserMessage', () {
|
group('errorToUserMessage', () {
|
||||||
test('null falls back to the default message', () {
|
test('null falls back to the default message', () {
|
||||||
@@ -165,6 +168,96 @@ void main() {
|
|||||||
expect(errorToUserMessage(ex), contains('Zu viele Anfragen'));
|
expect(errorToUserMessage(ex), contains('Zu viele Anfragen'));
|
||||||
expect(errorAllowsRetry(ex), isTrue);
|
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', () {
|
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(
|
Widget gatedPage() => Scaffold(
|
||||||
body: RouteTransitionGate(
|
body: RouteTransitionGate(
|
||||||
placeholder: const SizedBox(key: placeholder),
|
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 {
|
) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
unawaited(
|
unawaited(
|
||||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 50));
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
@@ -45,7 +52,9 @@ void main() {
|
|||||||
testWidgets('swaps back to placeholder while the route pops', (tester) async {
|
testWidgets('swaps back to placeholder while the route pops', (tester) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
unawaited(
|
unawaited(
|
||||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(gated), findsOneWidget);
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
@@ -63,13 +72,17 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
unawaited(
|
unawaited(
|
||||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(gated), findsOneWidget);
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 50));
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
@@ -84,6 +97,86 @@ void main() {
|
|||||||
expect(find.byKey(placeholder), findsNothing);
|
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', (
|
testWidgets('holds the child back for one frame without a route animation', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -99,3 +192,25 @@ void main() {
|
|||||||
expect(find.byKey(placeholder), findsNothing);
|
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