refactor and cleanup
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/foundation.dart' show immutable;
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
@@ -42,6 +43,28 @@ class _AppLinkEventsReceiver extends GeckoAppLinkEvents {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending prompt paired with the absolute instant it lapses.
|
||||
///
|
||||
/// [AppLinkPromptRequest.expiresInMs] is a *snapshot* taken when native answered
|
||||
/// the query — it does not tick down. Comparing that raw field to zero on a later
|
||||
/// build would treat a long-lapsed request as live (switch tabs for three minutes
|
||||
/// and come back, and a 90 s banner still reports 90 s), so the remaining time is
|
||||
/// anchored to a wall-clock deadline the moment the answer arrives.
|
||||
@immutable
|
||||
class PendingAppLinkPrompt {
|
||||
final AppLinkPromptRequest request;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const PendingAppLinkPrompt({required this.request, required this.expiresAt});
|
||||
|
||||
int get requestId => request.requestId;
|
||||
String get tabId => request.tabId;
|
||||
bool get isModal => request.isModal;
|
||||
|
||||
/// Whether native would still accept a resolution for this request.
|
||||
bool isLive(DateTime now) => expiresAt.isAfter(now);
|
||||
}
|
||||
|
||||
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||
/// event handler, queries the native pending store on attach/resume/event, and
|
||||
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||
@@ -52,7 +75,7 @@ class AppLinksCoordinator extends _$AppLinksCoordinator {
|
||||
final _service = GeckoAppLinksService();
|
||||
|
||||
@override
|
||||
List<AppLinkPromptRequest> build() {
|
||||
List<PendingAppLinkPrompt> build() {
|
||||
final receiver = _AppLinkEventsReceiver((owner) {
|
||||
if (owner == AppLinkPromptOwner.flutterBrowser) {
|
||||
// ignore: discarded_futures
|
||||
@@ -76,11 +99,22 @@ class AppLinksCoordinator extends _$AppLinksCoordinator {
|
||||
final prompts = await _service.getPendingAppLinkPrompts(
|
||||
AppLinkPromptOwner.flutterBrowser,
|
||||
);
|
||||
// Anchor the reported TTL immediately: every millisecond spent between the
|
||||
// native read and here has already been consumed.
|
||||
final queriedAt = DateTime.now();
|
||||
logger.i(
|
||||
'app-link refresh -> ${prompts.length} prompt(s): '
|
||||
'${prompts.map((p) => '${p.requestId}@${p.tabId}(${p.isModal ? 'modal' : 'banner'})').toList()}',
|
||||
);
|
||||
state = prompts;
|
||||
state = [
|
||||
for (final prompt in prompts)
|
||||
PendingAppLinkPrompt(
|
||||
request: prompt,
|
||||
expiresAt: queriedAt.add(
|
||||
Duration(milliseconds: prompt.expiresInMs),
|
||||
),
|
||||
),
|
||||
];
|
||||
} catch (error, stackTrace) {
|
||||
logger.w(
|
||||
'Failed to query pending app-link prompts',
|
||||
|
||||
@@ -23,7 +23,7 @@ final appLinksCoordinatorProvider = AppLinksCoordinatorProvider._();
|
||||
/// list is authoritative from the query and deduped by `requestId` — the event
|
||||
/// is only a nudge to re-query.
|
||||
final class AppLinksCoordinatorProvider
|
||||
extends $NotifierProvider<AppLinksCoordinator, List<AppLinkPromptRequest>> {
|
||||
extends $NotifierProvider<AppLinksCoordinator, List<PendingAppLinkPrompt>> {
|
||||
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||
/// event handler, queries the native pending store on attach/resume/event, and
|
||||
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||
@@ -48,16 +48,16 @@ final class AppLinksCoordinatorProvider
|
||||
AppLinksCoordinator create() => AppLinksCoordinator();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<AppLinkPromptRequest> value) {
|
||||
Override overrideWithValue(List<PendingAppLinkPrompt> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<AppLinkPromptRequest>>(value),
|
||||
providerOverride: $SyncValueProvider<List<PendingAppLinkPrompt>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$appLinksCoordinatorHash() =>
|
||||
r'183fc7ac1264a63c24b1d10f4a22cbfbf6046da7';
|
||||
r'3dc91825b659add92d2f651306c30b9aec4e557b';
|
||||
|
||||
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||
/// event handler, queries the native pending store on attach/resume/event, and
|
||||
@@ -66,22 +66,22 @@ String _$appLinksCoordinatorHash() =>
|
||||
/// is only a nudge to re-query.
|
||||
|
||||
abstract class _$AppLinksCoordinator
|
||||
extends $Notifier<List<AppLinkPromptRequest>> {
|
||||
List<AppLinkPromptRequest> build();
|
||||
extends $Notifier<List<PendingAppLinkPrompt>> {
|
||||
List<PendingAppLinkPrompt> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
WhenComplete runBuild() {
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<List<AppLinkPromptRequest>, List<AppLinkPromptRequest>>;
|
||||
as $Ref<List<PendingAppLinkPrompt>, List<PendingAppLinkPrompt>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
List<AppLinkPromptRequest>,
|
||||
List<AppLinkPromptRequest>
|
||||
List<PendingAppLinkPrompt>,
|
||||
List<PendingAppLinkPrompt>
|
||||
>,
|
||||
List<AppLinkPromptRequest>,
|
||||
List<PendingAppLinkPrompt>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
|
||||
+58
-23
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user