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