refactor and cleanup

This commit is contained in:
Fabian Freund
2026-08-09 05:53:02 +02:00
parent 54e277be72
commit cc27a6daa8
5 changed files with 175 additions and 61 deletions
@@ -57,50 +57,76 @@ class AppLinkPromptHost extends HookConsumerWidget {
// Native expiry is lazy — it only runs when the store is queried or consumed — and nothing
// pushes an expiry event. A prompt that outlives its deadline would keep rendering live
// buttons whose resolution is already a no-op, so drop anything already past due rather than
// offering an action that cannot happen.
final activeRequests = prompts
.where(
(request) => request.tabId == activeTabId && request.expiresInMs > 0,
)
// offering an action that cannot happen. The deadline is absolute
// ([PendingAppLinkPrompt.expiresAt]); the raw `expiresInMs` is only valid at query time.
final now = DateTime.now();
final livePrompts = prompts.where((prompt) => prompt.isLive(now)).toList();
final activeRequests = livePrompts
.where((prompt) => prompt.tabId == activeTabId)
.toList();
// ...and re-query when the soonest deadline passes, so a prompt retires itself on time
// instead of waiting for the next event or resume. The lower clamp matters: a request that
// reports 0 would otherwise reschedule instantly and spin.
final soonestExpiry = activeRequests.isEmpty
// instead of waiting for the next event or resume. Keyed on the absolute deadline so a
// rebuild (tab switch, unrelated state change) never re-arms a full-length timer from a
// stale TTL. The lower clamp matters: a deadline already reached would otherwise reschedule
// instantly and spin.
final soonestDeadline = livePrompts.isEmpty
? null
: activeRequests
.map((request) => request.expiresInMs)
.reduce((a, b) => a < b ? a : b);
: livePrompts
.map((prompt) => prompt.expiresAt)
.reduce((a, b) => a.isBefore(b) ? a : b);
useEffect(() {
if (soonestExpiry == null) return null;
if (soonestDeadline == null) return null;
final delay = soonestDeadline.difference(DateTime.now());
final timer = Timer(
Duration(milliseconds: soonestExpiry.clamp(250, 10 * 60 * 1000)),
() => unawaited(ref.read(appLinksCoordinatorProvider.notifier).refresh()),
Duration(milliseconds: delay.inMilliseconds.clamp(250, 10 * 60 * 1000)),
() =>
unawaited(ref.read(appLinksCoordinatorProvider.notifier).refresh()),
);
return timer.cancel;
}, [soonestExpiry]);
}, [soonestDeadline]);
final modalRequest = activeRequests
.where((request) => request.isModal)
.where((prompt) => prompt.isModal)
.lastOrNull;
// At most one banner per tab; a newer banner-class request simply becomes the
// one the UI renders.
final bannerRequest = activeRequests
.where((request) => !request.isModal)
.where((prompt) => !prompt.isModal)
.lastOrNull;
// A modal is shown at most once per requestId. Rotation/teardown is not a
// dismissal — the request stays pending and is re-presented on the next query
// (a subsequent build re-runs this effect with the still-present id).
final shownModalId = useRef<int?>(null);
// The route the modal lives on, so it can be retired without popping whatever
// else happens to sit on top of it.
final shownModalRoute = useRef<ModalRoute<void>?>(null);
// Ids we closed ourselves. Their pop must not be mistaken for a user
// dismissal, which would consume a request that is merely off-screen.
final retiredModalIds = useRef<Set<int>>(<int>{});
// Take down a dialog nobody can act on any more: its request expired, was
// invalidated (tab closed), or belongs to a tab the user has left. Without
// this the route just stays on screen with buttons that resolve to `stale`.
// Mirrors the native `NativeAppLinkPromptFeature.dismissStaleDialog`.
void retireShownModal() {
final shownId = shownModalId.value;
final route = shownModalRoute.value;
shownModalId.value = null;
shownModalRoute.value = null;
if (shownId == null || route == null || !route.isActive) return;
retiredModalIds.value.add(shownId);
route.navigator?.removeRoute(route);
}
useEffect(() {
final request = modalRequest;
if (request == null) {
shownModalId.value = null;
return null;
final shownId = shownModalId.value;
if (shownId != null && shownId != request?.requestId) {
retireShownModal();
}
if (shownModalId.value == request.requestId) {
if (request == null || shownModalId.value == request.requestId) {
return null;
}
shownModalId.value = request.requestId;
@@ -109,8 +135,17 @@ class AppLinkPromptHost extends HookConsumerWidget {
unawaited(
showDialog<void>(
context: context,
builder: (_) => AppLinkPromptDialog(request: request),
builder: (dialogContext) {
shownModalRoute.value = ModalRoute.of<void>(dialogContext);
return AppLinkPromptDialog(request: request.request);
},
).then((_) {
if (retiredModalIds.value.remove(request.requestId)) {
// We closed it, not the user. The request is either already gone or
// still pending for a tab that is no longer in front — either way it
// must not be consumed here.
return;
}
// Catch-all for a passive dismissal (Android back / touch-outside):
// the dialog buttons resolve the request themselves, but a barrier
// dismiss closes it without resolving, leaving the native request
@@ -134,7 +169,7 @@ class AppLinkPromptHost extends HookConsumerWidget {
return AppLinkOpenBanner(
key: ValueKey(bannerRequest.requestId),
request: bannerRequest,
request: bannerRequest.request,
);
}
}