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() {
|
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