diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b545e9..af1897e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added +- **A push permission soft prompt component**, at `lib/src/ui/components/push_prompt/` following the `notification_dropdown/` folder shape (component, recipe, preview, index). `PushPrompt` renders the four presentations `pushPromptAdvice` can produce (`unavailable`, `blocked` with or without a settings route back, `off` asking or already declined, `on`) from `reachability`/`action`/`declined`/`busy` and reports two callbacks; it touches no platform API itself. `PushPromptHost` wires it to `Notify.manager.pushPromptAdvice()`, re-reading on the attached driver's `onPermissionChanged` and `onIdentityChanged` streams so a grant landing OUT OF BAND still clears the row, and on `NotificationManager.onPushDriverAttached` so a driver that resolves after the widget mounts is picked up too rather than leaving the row on `unavailable` until it remounts. That is the common path, since `requestPermission()` on an already-denied device opens the platform settings page rather than raising a dialog. `PushOffNotice` is a quiet, tappable shell marker for wherever a host's own chrome wants one, following the same two device streams and the same late-driver signal. + + **Two decisions carried over from where this pattern was proven, both load-bearing.** `PushPromptHost` takes the vault key for its decline timestamp as a REQUIRED constructor parameter rather than owning a fixed one: this package already refuses to own the decline itself (`pushPromptAdvice(declinedAt:)` takes it as an argument, for the same reason: the decline is the host's own UI event), and a fixed key here would either collide with whatever a host already stores or force every host onto one name. The widget only ever READS an ISO-8601 instant back; a value some older build wrote in a different shape is the host's own migration to make, once, before ever constructing this widget. `PushOffNotice` takes a required `onOpenPreferences` callback instead of navigating to a fixed route, so this package gains no dependency on any starter kit's routing convention. + + **This change also adds `PushIdentityReconciled` and `NotificationManager.onPushIdentityReconciled`, exported from the barrel and documented from day one.** A host reporting device state per person (who this device is subscribed as, posted to its own backend) needs to know when a reconcile pass RAN, not only when one converges, and had no supported way to ask for it before this. `doc/architecture/notification-manager.md` gains a section covering the three `(converged, error)` readings and the two cases nothing fires for (a driver-less pass, and a pass whose intent moved under it). + + Ships no translation catalogue of its own, matching every other `notifications.*` string in this package: the full `notifications.push_prompt.*` key list with English reference copy is now in `doc/basics/preferences.md`. The two colour roles it needs beyond the 17-key semantic alias contract (`up`/`on` and `degraded`/`blocked`) map onto `success` and `warning`, the two roles the contract ships with no `-container` tint of their own; the tile and its glyph go solid (`bg-success` / `bg-warning` with a literal `text-white`) rather than inventing one, following the pairing `toast.recipe.dart` already established for those roles. (`lib/src/ui/components/push_prompt/`, `lib/magic_notifications.dart`, `lib/src/notification_manager.dart`, `test/ui/components/push_prompt_test.dart`, `doc/basics/preferences.md`, `doc/architecture/notification-manager.md`) + ## [0.3.4] - 2026-09-22 ### Changed diff --git a/README.md b/README.md index 626040d..fa0220a 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,27 @@ switch (reachability) { > while the push reaches nobody. There is no workaround; the app has to tell > the person to add the site to their Home Screen first. +### Push Permission Soft Prompt + +`PushPrompt`, `PushPromptHost`, and `PushOffNotice` ask for push before the +platform's own one-shot prompt is spent, and mark a device push cannot reach +on screens outside the settings page: + +```dart +PushPromptHost(declinedVaultKey: 'my_app.push_prompt_declined') + +PushOffNotice(onOpenPreferences: () => MagicRoute.to('/settings/notifications')) +``` + +`PushPromptHost` owns the decline timestamp under the vault key you give it; +the package owns the policy of when to ask again. See the +[Push Prompt Component](doc/basics/preferences.md#push-prompt) section for the +four presentations and the translation keys a host has to add. + +`NotificationManager.onPushIdentityReconciled` streams the outcome of every +identity reconcile pass (login, logout, or a driver attaching later): see +[Push Identity Reconcile Outcomes](doc/architecture/notification-manager.md#identity-reconciled). + ### Clean Up on Logout ```dart diff --git a/doc/architecture/notification-manager.md b/doc/architecture/notification-manager.md index b736457..ce200a1 100644 --- a/doc/architecture/notification-manager.md +++ b/doc/architecture/notification-manager.md @@ -10,6 +10,7 @@ - [Realtime Delivery](#realtime) - [Stream Management](#streams) - [Optimistic Updates with Rollback](#optimistic) +- [Push Identity Reconcile Outcomes](#identity-reconciled) --- @@ -390,6 +391,58 @@ Future deleteNotification(String id) async { --- +## Push Identity Reconcile Outcomes + +`reconcilePushIdentity()` brings the device's subscribed identity in line with +`pushIntent` (see [Push Driver Setup](#push-driver) for how a driver +resolves). `onPushIdentityReconciled` is the outcome of every pass that had a +driver to act on: + +```dart +Notify.manager.onPushIdentityReconciled.listen((PushIdentityReconciled event) { + reportDeviceState(intent: event.intent, converged: event.converged); +}); +``` + +```dart +class PushIdentityReconciled { + final String? intent; // captured when the pass started + final bool converged; // whether the device carried it by the end + final Object? error; // non-null only when the SDK call itself failed +} +``` + +This exists for a host that reports device state PER PERSON (who this +device is subscribed as, posted to the host's own backend), which needs to +know when a pass RAN, not only when one converges. A converged-only signal +leaves that host silent until the next auth bump happens to trigger another +pass, and the first pass after a driver attaches is exactly the one most +likely to throw or read back a mismatch. + +Three things to read `converged` and `error` together for: + +| `converged` | `error` | Meaning | +|---|---|---| +| `true` | `null` | The device carries `intent`. | +| `false` | non-null | The SDK call itself failed (network, driver refusal). | +| `false` | `null` | The call was issued and the read-back simply did not agree. | + +`intent` is `null` on a sign-out pass, not only on a driver-less one: a +listener that treats a `null` intent as "nothing happened" misses every +sign-out. Nothing fires for a pass that had NO driver to act on at all (a +build with no push configured has nothing to report convergence about), and a +pass whose intent moved underneath it (a second login landing mid-pass) does +not fire either: the mover's OWN pass reports for them, so a joiner never +double-reports one outcome under two identities. + +`onPushDriverAttached` is the companion stream for the same host: a cold boot +that restores a session normally bumps auth, and with it a login `want()`, +before `NotificationServiceProvider.boot()` has resolved a driver, so +anything the host does WITH a driver on that path needs to know when one +shows up rather than only when auth changes. + +--- + **Related** - [Service Provider](https://magic.fluttersdk.com/packages/notifications/architecture/service-provider) diff --git a/doc/basics/preferences.md b/doc/basics/preferences.md index 8b8c23c..d87ad8b 100644 --- a/doc/basics/preferences.md +++ b/doc/basics/preferences.md @@ -8,6 +8,7 @@ - [Global Toggles vs Per-Type Preferences](#global-vs-type) - [API Endpoints](#api) - [UI Integration Example](#ui) +- [Push Prompt Component](#push-prompt) --- @@ -302,6 +303,79 @@ class User extends Model with Notifiable { --- +## Push Prompt Component + +`PushPrompt`, `PushPromptHost` and `PushOffNotice` +(`lib/src/ui/components/push_prompt/`) are the package's own push-permission +soft prompt: a row asking for push BEFORE the platform's one-shot prompt is +spent, built on `NotificationManager.pushPromptAdvice()`. + +- **`PushPrompt`** is presentational: given `reachability`, `action`, + `declined` and `busy`, it renders one of four presentations and reports + `onEnable` / `onDecline`. It touches no platform API itself. +- **`PushPromptHost`** wires it to the live device. It owns nothing about + WHEN to ask (`pushPromptAdvice` does), but it owns the one thing the package + refuses to: the moment the user last declined it on this device. That is why + it takes a `declinedVaultKey` constructor parameter rather than a fixed key: + + ```dart + const PushPromptHost(declinedVaultKey: 'my_app.push_prompt_declined') + ``` + + It reads that key as an ISO-8601 timestamp and nothing else; a value an + older build wrote in some other shape is the host's own migration to make, + once, before ever constructing this widget. + +- **`PushOffNotice`** is a quiet shell marker (a sidebar row, or a compact + glyph for a mobile top bar) that tells a person, on a screen they were + already looking at, that this device cannot be reached by push. Tapping it + calls the required `onOpenPreferences` callback, which a host wires to + wherever `PushPromptHost` and its controls actually live: + + ```dart + PushOffNotice( + onOpenPreferences: () => MagicRoute.to('/settings/notifications'), + ) + ``` + +### Translation keys + +This package ships no catalogue of its own, and `PushPrompt`/`PushOffNotice` +resolve every string through `trans('notifications.push_prompt.*')`. A host +adds all of the following to every locale it ships; a missing key renders as +itself. + +| Key | English reference copy | +|-----|-------------------------| +| `notifications.push_prompt.unavailable_body` | This build has no push notifications. | +| `notifications.push_prompt.on_body` | You're all set to receive push notifications. | +| `notifications.push_prompt.blocked_title` | Notifications are blocked | +| `notifications.push_prompt.blocked_body_settings` | Turn notifications back on in your device settings. | +| `notifications.push_prompt.blocked_body_web` | Open the padlock icon in your browser's address bar to allow notifications. | +| `notifications.push_prompt.blocked_body_ios` | Open Settings, then Notifications, to allow notifications for this app. | +| `notifications.push_prompt.blocked_body_android` | Open this app's notification settings to allow notifications. | +| `notifications.push_prompt.declined_body` | You turned off this reminder. You can still enable push any time. | +| `notifications.push_prompt.ask_title` | Turn on notifications | +| `notifications.push_prompt.ask_body` | Get notified the moment something needs your attention. | +| `notifications.push_prompt.open_settings` | Open settings | +| `notifications.push_prompt.enable` | Enable | +| `notifications.push_prompt.not_now` | Not now | +| `notifications.push_prompt.shell_notice` | Push is off | +| `notifications.push_prompt.shell_notice_a11y` | Push notifications are off | + +### Colour roles + +`PushPrompt`'s `blocked` and `on` presentations map to the `warning` and +`success` roles of the 17-key semantic alias contract (`design:sync`'s +`_aliasMappings`). Neither role ships a `-container` tint the way +`destructive` does, so the tile and its glyph go solid (`bg-warning` / +`bg-success` with a literal `text-white`) rather than inventing one, the same +pairing `toast.recipe.dart` already uses for these two roles. A host that +wants a softer treatment restyles by copying the recipe file; the component +exposes no per-instance className override today. + +--- + **Related** - [Channels](https://magic.fluttersdk.com/packages/notifications/basics/channels) diff --git a/lib/magic_notifications.dart b/lib/magic_notifications.dart index 04d495e..3263a44 100644 --- a/lib/magic_notifications.dart +++ b/lib/magic_notifications.dart @@ -8,6 +8,7 @@ export 'src/models/database_notification.dart'; export 'src/models/notification_preference.dart'; export 'src/models/paginated_notifications.dart'; export 'src/models/push_delivery_snapshot.dart'; +export 'src/models/push_identity_reconciled.dart'; export 'src/models/push_message.dart'; export 'src/models/push_prompt_advice.dart'; export 'src/models/push_subscription.dart'; @@ -36,6 +37,7 @@ export 'src/ui/notification_view_registry.dart'; export 'src/ui/views/notifications_list_view.dart'; export 'src/ui/views/notification_preferences_view.dart'; export 'src/ui/components/notification_dropdown/index.dart'; +export 'src/ui/components/push_prompt/index.dart'; export 'src/http/notification_preferences_controller.dart'; export 'src/http/notifications_list_controller.dart'; diff --git a/lib/src/models/push_identity_reconciled.dart b/lib/src/models/push_identity_reconciled.dart new file mode 100644 index 0000000..2b2eead --- /dev/null +++ b/lib/src/models/push_identity_reconciled.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart' show immutable; + +/// The outcome of one push identity reconcile pass that had a driver to act +/// on. +/// +/// A host that reports device state per person (posting who this device is +/// subscribed as) needs to know when a pass RAN, not only when one converges: +/// a pass that throws, or reads back a mismatch, still has to be reported so +/// the host does not keep announcing state for an intent the device never +/// took on. See [NotificationManager.onPushIdentityReconciled]. +@immutable +class PushIdentityReconciled { + /// The intent this pass reconciled against, captured when the pass started. + /// + /// Not necessarily what the device holds now: on failure, or on a mismatch + /// the read-back caught, the device may still carry whoever it had before. + final String? intent; + + /// Whether the device carried [intent] by the end of the pass. + final bool converged; + + /// The failure the pass raised, or `null` when it did not throw. + /// + /// A `false` [converged] with a `null` error means the SDK call was issued + /// and the read-back simply did not agree; a non-null error means the call + /// itself failed. + final Object? error; + + /// Creates one reconcile outcome. + const PushIdentityReconciled({ + required this.intent, + required this.converged, + this.error, + }); +} diff --git a/lib/src/notification_manager.dart b/lib/src/notification_manager.dart index 53b4775..453d221 100644 --- a/lib/src/notification_manager.dart +++ b/lib/src/notification_manager.dart @@ -10,6 +10,7 @@ import 'exceptions/notification_exception.dart'; import 'models/database_notification.dart'; import 'models/paginated_notifications.dart'; import 'models/push_delivery_snapshot.dart'; +import 'models/push_identity_reconciled.dart'; import 'models/push_prompt_advice.dart'; import 'models/push_subscription.dart'; import 'models/push_user_attributes.dart'; @@ -206,6 +207,12 @@ class NotificationManager { final StreamController _pushDriverAttachedController = StreamController.broadcast(); + /// One outcome per reconcile pass that had a driver to act on. See + /// [onPushIdentityReconciled]. + final StreamController + _pushIdentityReconciledController = + StreamController.broadcast(); + /// The end of a session, for anything holding notification state of its own. /// See [onSessionCleared]. final StreamController _sessionClearedController = @@ -865,6 +872,26 @@ class NotificationManager { Stream get onPushDriverAttached => _pushDriverAttachedController.stream; + /// One outcome per reconcile pass that had a driver to act on. + /// + /// A host reporting device state per person needs to know when a pass RAN, + /// not only when one converges: the first pass after a driver attaches can + /// throw, or read back a mismatch, and a converged-only signal would leave + /// that host silent until the next auth bump happens to trigger another + /// pass. Fires from inside [_runPushIdentityPass], after its try/catch, + /// carrying the pass's captured intent, [isPushIdentityConverged] and + /// [pushIdentityError] as they stood at the end of that pass. + /// + /// Nothing fires for the driver-less early return: a build with no push + /// configured has nothing to report convergence about. + /// + /// Guarded on the intent not having moved since the pass captured it: a + /// pass whose subject changed underneath it belongs to whoever holds the + /// intent now, not to this one, and that mover's own pass is what reports + /// for them. + Stream get onPushIdentityReconciled => + _pushIdentityReconciledController.stream; + /// The external id this device should be subscribed as, `null` for nobody. String? get pushIntent => _pushIntent; @@ -1070,6 +1097,19 @@ class NotificationManager { '"${intent ?? 'sign-out'}": $e', ); } + + // Only when the intent has not moved since this pass captured it: a + // caller that changed it while this pass was in the air owns its own + // pass, and reports for it there. See [onPushIdentityReconciled]. + if (_pushIntent == intent && !_pushIdentityReconciledController.isClosed) { + _pushIdentityReconciledController.add( + PushIdentityReconciled( + intent: intent, + converged: _pushIdentityConverged, + error: _pushIdentityError, + ), + ); + } } /// Applies what the SDK itself reports about the device's identity. diff --git a/lib/src/ui/components/push_prompt/index.dart b/lib/src/ui/components/push_prompt/index.dart new file mode 100644 index 0000000..717ec0d --- /dev/null +++ b/lib/src/ui/components/push_prompt/index.dart @@ -0,0 +1,7 @@ +// PushPrompt component: folder-local barrel. +// +// Re-exports the public surface (the presentational component, its +// platform-wired host, its shell marker, and the recipes). + +export 'push_prompt.dart' show PushOffNotice, PushPrompt, PushPromptHost; +export 'push_prompt.recipe.dart'; diff --git a/lib/src/ui/components/push_prompt/push_prompt.dart b/lib/src/ui/components/push_prompt/push_prompt.dart new file mode 100644 index 0000000..ccdcaea --- /dev/null +++ b/lib/src/ui/components/push_prompt/push_prompt.dart @@ -0,0 +1,891 @@ +import 'dart:async' show StreamSubscription, unawaited; + +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '../../../drivers/push/push_driver.dart'; +import '../../../facades/notify.dart'; +import '../../../models/push_prompt_advice.dart'; +import '../../../models/push_subscription.dart' show PushReachability; +import '../../../notification_manager.dart' show NotificationManager; +import '../../../support/notification_log.dart'; +import 'push_prompt.recipe.dart'; + +/// **The push permission soft prompt.** +/// +/// A single row explaining what push notifications buy the user and asking +/// for them BEFORE the platform's own one-shot prompt is spent. Presentational +/// on purpose: it renders the [reachability] and [action] it is handed and +/// reports the two decisions back, so its whole surface is reachable from a +/// widget test. [PushPromptHost] is the half that asks +/// `Notify.manager.pushPromptAdvice()` what this device's state actually is. +/// +/// ### Why it takes an [action] as well as a [reachability] +/// +/// Reachability alone cannot name a presentation. A `blocked` device on mobile +/// still has a route back (the SDK's `fallbackToSettings` lands a request on +/// the app's settings page), and a `blocked` browser has none, because no web +/// API opens the site settings panel from a page. That split is a property of +/// the PLATFORM, not of the reading, and the package answers it in +/// [PushPromptAction] rather than leaving each consumer to guess. +/// +/// ### The four presentations +/// +/// - **`unavailable`** ([PushPromptAction.none]): this build has no push driver +/// at all. One muted line. +/// - **`blocked`**: the OS prompt is spent. With +/// [PushPromptAction.openSettings] the row keeps a real control that opens +/// the platform setting; with [PushPromptAction.instructions] there is +/// nowhere to send a tap, so it says where the switch lives instead of +/// offering a control that silently does nothing. +/// - **`off`** ([PushPromptAction.request]): a real dialog will appear. Not yet +/// resolved ([declined] false) shows the soft prompt with an explicit +/// decline; a resolved ask ([declined] true) leaves the compact enable +/// control, because a declined soft prompt must never be a dead end. +/// - **`on`** ([PushPromptAction.none]): subscribed. One confirming line. +/// +/// ### Example Usage: +/// +/// ```dart +/// final advice = await Notify.manager.pushPromptAdvice(); +/// +/// PushPrompt( +/// reachability: advice.reachability, +/// action: advice.action, +/// onEnable: () => Notify.requestPushPermission(), +/// onDecline: () => myVault.recordDecline(), +/// ) +/// ``` +@immutable +class PushPrompt extends StatelessWidget { + /// Identifies the instruction row a blocked device with no route back gets. + /// + /// Exported rather than private because "a platform that cannot open its own + /// setting renders an instruction and NOT a control" is the assertion this + /// component exists to hold, and a test should not have to match on copy to + /// make it. It is deliberately absent from the [PushPromptAction.openSettings] + /// arm, which is a control. + static const ValueKey blockedInstructionKey = ValueKey( + 'push-prompt-blocked-instruction', + ); + + /// The glyph for the soft prompt and the compact enable row. + static const IconData _askIcon = Icons.notifications_active_outlined; + + /// The glyph for the blocked row. + static const IconData _blockedIcon = Icons.notifications_off_outlined; + + /// The glyph for the subscribed row. + static const IconData _onIcon = Icons.check_circle_outline; + + /// Whether push can reach this device right now, as the platform reports it. + final PushReachability reachability; + + /// What this row's control can actually accomplish here, as + /// `Notify.manager.pushPromptAdvice()` resolved it. + final PushPromptAction action; + + /// Whether the soft ask has already been resolved on this device. + /// + /// Only meaningful while [action] is [PushPromptAction.request]: it swaps the + /// soft prompt for the compact enable control. + final bool declined; + + /// Whether an enable request is in flight, driving the button's spinner. + final bool busy; + + /// Invoked when the user asks for push. The caller owns the platform + /// request; this widget never touches the SDK. + final Future Function()? onEnable; + + /// Invoked when the user declines the soft prompt. + final VoidCallback? onDecline; + + /// Creates a [PushPrompt] for the given [reachability] and [action]. + const PushPrompt({ + super.key, + required this.reachability, + required this.action, + this.declined = false, + this.busy = false, + this.onEnable, + this.onDecline, + }); + + /// The recipe state axis value for the current presentation. + /// + /// Keyed on [reachability] rather than [action]: the tokens carry what the + /// device's STATE is (a blocked device gets the warning tint whether or not + /// this platform can route the tap back), while [action] decides what the + /// body offers. + String get _state => switch (reachability) { + PushReachability.unavailable => kPushPromptStateUnavailable, + PushReachability.blocked => kPushPromptStateBlocked, + PushReachability.on => kPushPromptStateOn, + PushReachability.off => kPushPromptStateAsk, + }; + + /// The glyph for the current presentation. + IconData get _icon => switch (reachability) { + PushReachability.unavailable => _blockedIcon, + PushReachability.blocked => _blockedIcon, + PushReachability.on => _onIcon, + PushReachability.off => _askIcon, + }; + + /// Where the user has to go to unblock notifications. + /// + /// Three answers rather than one, because the setting lives somewhere + /// different on each: a browser hides it behind the padlock in the address + /// bar, iOS keeps it under Settings, Android under the app's own entry. + String get _blockedInstruction { + if (kIsWeb) return trans('notifications.push_prompt.blocked_body_web'); + + return switch (defaultTargetPlatform) { + TargetPlatform.iOS || + TargetPlatform.macOS => + trans('notifications.push_prompt.blocked_body_ios'), + _ => trans('notifications.push_prompt.blocked_body_android'), + }; + } + + @override + Widget build(BuildContext context) { + final String state = _state; + + return WDiv( + className: pushPromptRecipe( + variants: {kPushPromptStateAxis: state}, + ), + children: [ + WDiv( + className: pushPromptTileRecipe( + variants: {kPushPromptStateAxis: state}, + ), + child: WIcon( + _icon, + className: pushPromptIconRecipe( + variants: {kPushPromptStateAxis: state}, + ), + ), + ), + WDiv( + className: 'min-w-0 flex-1 flex flex-col gap-2', + children: _buildBody(), + ), + ], + ); + } + + /// The message column for the current presentation. + /// + /// Driven by [action], because that is the only input that knows what a tap + /// could accomplish. [reachability] appears once, to tell the two states with + /// nothing to offer apart: a subscribed device and a build with no push. + List _buildBody() { + return switch (action) { + PushPromptAction.none when reachability == PushReachability.on => [ + _buildLine(trans('notifications.push_prompt.on_body')), + ], + PushPromptAction.none => [ + _buildLine(trans('notifications.push_prompt.unavailable_body')), + ], + // The OS prompt is spent, but this platform routes the same request to + // the app's settings page, so the row is a control again. + PushPromptAction.openSettings => [ + _buildTitle(trans('notifications.push_prompt.blocked_title')), + _buildLine(trans('notifications.push_prompt.blocked_body_settings')), + WDiv( + className: pushPromptActionsClassName, + children: [_buildEnable()], + ), + ], + // Nowhere to send a tap. A control here would silently do nothing, so + // the row says where the switch actually lives instead. + PushPromptAction.instructions => [ + _buildTitle(trans('notifications.push_prompt.blocked_title')), + WDiv( + key: blockedInstructionKey, + child: _buildLine(_blockedInstruction), + ), + ], + PushPromptAction.request when declined => [ + _buildLine(trans('notifications.push_prompt.declined_body')), + WDiv( + className: pushPromptActionsClassName, + children: [_buildEnable()], + ), + ], + PushPromptAction.request => [ + _buildTitle(trans('notifications.push_prompt.ask_title')), + _buildLine(trans('notifications.push_prompt.ask_body')), + WDiv( + className: pushPromptActionsClassName, + children: [_buildEnable(), _buildDecline()], + ), + ], + }; + } + + /// A heading line. + Widget _buildTitle(String text) { + return WText(text, className: 'text-sm font-semibold text-fg'); + } + + /// A body line. + Widget _buildLine(String text) { + return WText(text, className: 'text-sm leading-relaxed text-fg-muted'); + } + + /// The primary action, labelled for what the tap will actually do. + /// + /// One control and one callback for both arms, because the platform call is + /// the same one: `requestPermission()` raises the dialog on a device that has + /// never been asked, and opens the app's settings page on one that has. Only + /// the promise made to the user changes, and promising "turn on push" where + /// the tap opens Settings is the kind of small lie that costs the next tap. + Widget _buildEnable() { + final String label = action == PushPromptAction.openSettings + ? trans('notifications.push_prompt.open_settings') + : trans('notifications.push_prompt.enable'); + + return WButton( + key: const ValueKey('push-prompt-enable'), + onTap: busy ? null : onEnable, + isLoading: busy, + loadingSize: 14, + className: pushPromptEnableButtonClassName, + child: WText(label), + ); + } + + /// The decline action, which resolves the soft ask WITHOUT touching the + /// platform's one-shot prompt. + Widget _buildDecline() { + return WButton( + key: const ValueKey('push-prompt-decline'), + onTap: busy ? null : onDecline, + className: pushPromptDeclineButtonClassName, + child: WText(trans('notifications.push_prompt.not_now')), + ); + } +} + +/// The reading both platform-wired widgets below fall back to when the +/// platform read throws: nothing known about this device, and nothing to +/// offer. +/// +/// Deliberately the same answer a build with no push driver gets. A failed +/// read is not evidence that push works, and the two are indistinguishable +/// from here; what neither of them is, is a reason to raise an error out of a +/// lifecycle path. +const PushPromptAdvice _unreadableDevice = PushPromptAdvice( + show: false, + reachability: PushReachability.unavailable, + action: PushPromptAction.none, +); + +/// **The push prompt wired to the platform.** +/// +/// Reads the one fact `NotificationManager.pushPromptAdvice` refuses to own, +/// the moment the user last turned the reminder down on THIS device, from +/// [declinedVaultKey], hands it to `Notify.manager.pushPromptAdvice()`, and +/// renders [PushPrompt] from the answer. +/// +/// ### Why the host supplies the vault key and the package stores nothing +/// +/// The POLICY (is a reminder due, and what can its control do) is the part +/// two consumers would each get wrong in their own way, so it lives in +/// `NotificationManager.pushPromptAdvice`. The decline is the HOST's own UI +/// event, and a second copy inside this package would be a second answer to +/// drift out of sync with the host's own. The two meet at +/// `pushPromptAdvice(declinedAt:)`. +/// +/// The vault key itself is a host parameter for the same reason: a fixed key +/// here would collide with whatever else a host already stores, or force +/// every host onto one name. See [declinedVaultKey] for what this widget does +/// and does not do with whatever it reads back. +class PushPromptHost extends StatefulWidget { + /// Creates a [PushPromptHost] reading its decline timestamp from + /// [declinedVaultKey]. + const PushPromptHost({super.key, required this.declinedVaultKey}); + + /// The [Vault] key recording WHEN the reminder was last turned down on this + /// device. + /// + /// The value this widget WRITES is always an ISO-8601 instant in UTC; UTC + /// rather than local wall-clock, because a wall-clock string parsed back in + /// a different zone can name an instant most of a reprompt interval away + /// from the one it recorded. + /// + /// This widget only ever READS an ISO-8601 timestamp: anything else stored + /// under this key (absent, or a value some other shape wrote) is read as + /// "never declined". It does not migrate an older value in place, unlike + /// the value this key held before this widget existed on some apps; a host + /// carrying such a value migrates it itself, once, before ever constructing + /// this widget, because only the host knows what shape that value is in. + final String declinedVaultKey; + + @override + State createState() => _PushPromptHostState(); +} + +class _PushPromptHostState extends State { + /// The package's answer, or null while the first read is in flight. + PushPromptAdvice? _advice; + + /// When the reminder was last turned down on this device, or null. + DateTime? _declinedAt; + + /// Rises by one every time [_read], [_decline], or [_enable] starts a new + /// pass through [_apply]. + /// + /// A permission or identity event can fire [_read] while an earlier + /// [_read] (or [_decline]) is still waiting on the platform, and the two + /// can then land in either order. Without a generation to compare against, + /// whichever finishes LAST wins the screen even when it started first, so a + /// decline that already landed can be undone by a read that was already + /// stale the moment it started. [_apply] drops any call whose generation is + /// no longer the latest one issued, rather than trusting arrival order. + int _generation = 0; + + /// Whether the platform prompt has already been raised in THIS session. + /// + /// Not persisted, and separate from [_declinedAt] because it answers a + /// different question: a granted request whose subscription has not arrived + /// yet still reads as `off`, and asking again in the same breath is noise. + /// Both collapse into the compact enable row. + bool _asked = false; + + /// Whether an enable request is in flight. + bool _busy = false; + + /// The driver reports this widget listens to while it is mounted. + /// + /// The same pair, and for the same reason, as [PushOffNotice]: either stream + /// can end the state this prompt is about. It matters MORE here, because + /// this is the surface a user is sent to in order to fix push, and the fix + /// almost always lands out of band. `requestPermission` on an + /// already-denied device opens the platform settings page rather than a + /// dialog, so the grant arrives while this widget is backgrounded and + /// unchanged; and a granted request whose subscription has not landed yet + /// reads as `off` until the identity stream carries it (see [_asked]). + /// Without these two, the one screen that exists to turn push on is the + /// only one that never notices push was turned on. + final List> _watching = + >[]; + + /// The driver [_watching] currently follows, or null while none has been + /// found yet. + /// + /// Tracked so a later attachment can tell "no driver yet" apart from + /// "already following this one" and, on a replacement, cancel the old pair + /// before wiring the new one rather than leaking a subscription to a + /// driver nobody uses any more. + PushDriver? _watchedDriver; + + /// Follows [NotificationManager.onPushDriverAttached], for a driver that + /// resolves after this widget is already mounted. + /// + /// A build with no factory registered at `initState` finds no driver in + /// [_watch] and stops there for good without this: `pushDriverOrNull` + /// resolves and attaches lazily, on whichever call happens to read it + /// first, and that call is not necessarily this widget's own. Without + /// following the attachment announcement, this host would read + /// `unavailable` forever once the driver becomes available in the same + /// session, since nothing else prompts a re-read. + StreamSubscription? _driverAttachedSubscription; + + @override + void initState() { + super.initState(); + unawaited(_read()); + _watch(); + _driverAttachedSubscription = Notify.manager.onPushDriverAttached.listen( + _onDriverAttached, + onError: (Object error) => NotificationLog.error( + '[PushPromptHost] driver-attached stream failed: $error', + ), + ); + } + + @override + void dispose() { + unawaited(_driverAttachedSubscription?.cancel()); + _driverAttachedSubscription = null; + _cancelDriverWatchers(); + super.dispose(); + } + + /// Follows everything the driver reports about this device's state. + /// + /// Re-reads through [_read] rather than [_apply] so the stored decline is + /// re-fetched too: a grant arriving after a decline has to clear the compact + /// row, not re-render it against a stale timestamp. + void _watch() { + final PushDriver? driver = Notify.manager.pushDriverOrNull; + if (driver == null) return; + + _watchDriver(driver); + } + + /// Reacts to a driver resolving or being replaced after this widget already + /// mounted: re-reads the advice, then follows [driver]'s own streams. + void _onDriverAttached(PushDriver driver) { + _watchDriver(driver); + unawaited(_read()); + } + + /// Wires [_watching] to [driver], first cancelling any subscription to a + /// previous one. + /// + /// Keyed on identity rather than unconditionally replacing: an attachment + /// announcement is not necessarily a NEW driver (see [_onDriverAttached]), + /// and re-subscribing to the one already followed would only cost a frame + /// of duplicate listeners. + void _watchDriver(PushDriver driver) { + if (identical(_watchedDriver, driver)) return; + + _cancelDriverWatchers(); + _watchedDriver = driver; + + _watching.add( + driver.onPermissionChanged.listen( + (_) => unawaited(_read()), + onError: (Object error) => NotificationLog.error( + '[PushPromptHost] permission stream failed: $error', + ), + ), + ); + _watching.add( + driver.onIdentityChanged.listen( + (_) => unawaited(_read()), + onError: (Object error) => NotificationLog.error( + '[PushPromptHost] identity stream failed: $error', + ), + ), + ); + } + + /// Cancels every subscription in [_watching] and forgets [_watchedDriver]. + void _cancelDriverWatchers() { + for (final StreamSubscription subscription in _watching) { + unawaited(subscription.cancel()); + } + _watching.clear(); + _watchedDriver = null; + } + + /// Reads the decline this device carries, then what the package makes of it. + Future _read() async { + final int generation = ++_generation; + await _apply(generation, await _readDeclinedAt()); + } + + /// Re-derives the advice for [declinedAt] and puts both on screen, unless + /// [generation] has already been overtaken by a newer [_read], [_decline], + /// or [_enable]. + /// + /// The one place this widget's state moves, so the timestamp it asked with + /// and the answer it got can never be a frame apart. The generation check + /// is what keeps a call that started earlier but answers later from + /// clobbering one that started after it and already landed; see + /// [_generation]. + Future _apply(int generation, DateTime? declinedAt) async { + final PushPromptAdvice advice = await _readAdvice(declinedAt); + + if (!mounted || generation != _generation) return; + + setState(() { + _declinedAt = declinedAt; + _advice = advice; + }); + } + + /// Asks the package what to do, answering "nothing to offer" when the + /// platform read throws. + /// + /// `pushPromptAdvice` reaches `permissionState()` through `reachability()`, + /// a platform-channel call that can throw, and it does not guard that read + /// itself the way `pushDeliverySnapshot()` does. Left unhandled the throw + /// escapes as an unhandled async error and [_advice] stays null, which + /// renders nothing at all rather than a state the user can act on. + Future _readAdvice(DateTime? declinedAt) async { + try { + return await Notify.manager.pushPromptAdvice(declinedAt: declinedAt); + } catch (error) { + NotificationLog.warning( + '[PushPromptHost] push prompt advice failed: $error', + ); + + return _unreadableDevice; + } + } + + /// Reads the persisted decline timestamp. + /// + /// Anything that is not a valid ISO-8601 instant (nothing stored, or a + /// value written in some other shape) reads as "never declined" rather + /// than being repaired here: see [PushPromptHost.declinedVaultKey] for why + /// that migration is the host's to make, once, before this widget exists. + /// + /// A vault failure answers null too, because a broken read must not take the + /// preference screen down and the safe default is to ask: the reminder is a + /// question, not an action. + Future _readDeclinedAt() async { + final String? stored = await _readVault(); + if (stored == null) return null; + + return DateTime.tryParse(stored); + } + + /// The raw stored value, or null when there is none or the vault is + /// unreachable. + Future _readVault() async { + try { + return await Vault.get(widget.declinedVaultKey); + } catch (error) { + NotificationLog.warning('[PushPromptHost] vault read failed: $error'); + + return null; + } + } + + /// Writes [at] to this device, answering whether it landed. + Future _persistDeclinedAt(DateTime at) async { + try { + await Vault.put( + widget.declinedVaultKey, + at.toUtc().toIso8601String(), + ); + + return true; + } catch (error) { + NotificationLog.warning('[PushPromptHost] vault write failed: $error'); + + return false; + } + } + + /// Records the decline on this device, then re-reads the advice. + /// + /// The write comes FIRST and the row only changes when it landed. A decline + /// that failed to persist will not survive the next launch, so reporting it + /// as resolved would leave the user looking at a row that says their answer + /// was taken when it was not; the reminder (decline control and all) stays + /// on screen instead. + /// + /// The generation is taken AFTER the write lands, not before: a read that + /// starts while the write is still running reads the vault before the + /// decline is in it, so it is the stale one and has to lose. A failed write + /// takes no generation, so it drops no read that is still in flight. + Future _decline() async { + final DateTime at = DateTime.now().toUtc(); + if (!await _persistDeclinedAt(at)) return; + + await _apply(++_generation, at); + } + + /// Raises the platform request, then re-reads what the platform now says. + /// + /// One handler for both live actions, because both are the same call: on a + /// device that has never been asked it raises the dialog, and on a denied + /// one the driver's `fallbackToSettings` turns it into the app's settings + /// page. + /// + /// The request is guarded the same way the vault reads are: a throw is + /// logged rather than left to escape as an unhandled async error, and + /// control still falls through to [_apply] afterward so the row never gets + /// stuck on the spinner with a stale reading. The re-read keeps the decline + /// this device already carries rather than going back to the vault for it. + Future _enable() async { + if (_busy) return; + if (Notify.manager.pushDriverOrNull == null) return; + + setState(() { + _busy = true; + _asked = true; + }); + + try { + await Notify.requestPushPermission(); + } catch (error) { + NotificationLog.warning( + '[PushPromptHost] push permission request failed: $error', + ); + } finally { + if (mounted) setState(() => _busy = false); + } + + await _apply(++_generation, _declinedAt); + } + + /// Whether the host app has left the reminder turned on at all. + /// + /// Read here as well as inside `pushPromptAdvice`, and the duplication is + /// deliberate: `pushPromptAdvice` folds this switch into `advice.show`, but + /// a false `show` cannot say WHICH of the two reasons produced it, and the + /// two render differently. "Not due yet" still owes the user a status line + /// on a screen they opened on purpose; "switched off" owes them nothing at + /// all. + bool get _softPromptEnabled => + Config.get(NotificationManager.softPromptEnabledKey) ?? true; + + @override + Widget build(BuildContext context) { + if (!_softPromptEnabled) return const SizedBox.shrink(); + + final PushPromptAdvice? advice = _advice; + + // Nothing is known yet. One frame, and only ever the first: every other + // state below renders a row, so this cannot become a permanently empty + // child costing a gap slot in a flex column that expects one. + if (advice == null) return const SizedBox.shrink(); + + return PushPrompt( + reachability: advice.reachability, + action: advice.action, + // `show` is the package's answer to "may I interrupt", and a screen the + // user opened deliberately still states the device's status when the + // answer is no. That is exactly the compact row: no title, no decline, + // and the way back left in place. + declined: !advice.show || _asked, + busy: _busy, + onEnable: _enable, + onDecline: _decline, + ); + } +} + +/// **A quiet, tappable marker that this device cannot be reached by push.** +/// +/// A glyph and one line for a sidebar row, the glyph alone for a compact top +/// bar. Tapping it calls [onOpenPreferences], which a host wires to wherever +/// [PushPromptHost] and its controls actually live. +/// +/// ### Why it exists at all, and why it is not louder +/// +/// The soft prompt lives on a settings screen most people open rarely, so on +/// the surfaces they DO look at, a device that cannot be reached is +/// indistinguishable from one that can. It is still not an alarm: it carries +/// no colour beyond the glyph, and never blocks anything, which is also why +/// the reminder's own cadence lives in +/// `NotificationManager.repromptAfterHoursKey` rather than here. +/// +/// ### When it says nothing +/// +/// Exactly when there is nothing to do about it, which is +/// [PushPromptAction.none]: a device that is already subscribed, and a build +/// with no push driver at all (a platform the SDK does not cover, and a +/// platform read that failed). A permanent marker nobody can resolve is the +/// fastest way to train people to ignore the one that matters. +/// +/// ### Example Usage: +/// +/// ```dart +/// PushOffNotice(onOpenPreferences: () => MagicRoute.to('/settings/notifications')) +/// PushOffNotice(compact: true, onOpenPreferences: () => MagicRoute.to('/settings/notifications')) +/// ``` +class PushOffNotice extends StatefulWidget { + /// Creates the shell notice. + const PushOffNotice({ + super.key, + required this.onOpenPreferences, + this.compact = false, + }); + + /// Called when the marker is tapped. + /// + /// A required callback rather than a route this package navigates to + /// itself: this package does not know where a host mounts its preference + /// screen, and routing through a fixed path here would make this widget + /// depend on whatever starter kit or router convention a host happens to + /// use. The caller owns the navigation, the same way [PushPrompt]'s + /// [PushPrompt.onEnable] leaves the platform call to its caller. + final VoidCallback onOpenPreferences; + + /// Whether to render the glyph alone, for a bar with no room for a label. + final bool compact; + + @override + State createState() => _PushOffNoticeState(); +} + +class _PushOffNoticeState extends State { + /// The glyph. Extracted rather than written inline so the icon tree-shakes. + static const IconData _icon = Icons.notifications_off_outlined; + + /// What the package makes of this device, or null while the first read is in + /// flight. + PushPromptAdvice? _advice; + + /// The driver reports this widget listens to while it is mounted. + /// + /// Both of them, because either can end the state this marker is about: the + /// permission stream carries a grant, and the identity stream carries the + /// subscription landing afterwards, which is the half that turns an `off` + /// device into an `on` one. Without them a persistent shell keeps claiming + /// push is off for as long as this widget stays mounted, which can be the + /// whole session. + final List> _watching = + >[]; + + /// The driver [_watching] currently follows, or null while none has been + /// found yet. See [_PushPromptHostState._watchedDriver] for why this is + /// tracked rather than re-subscribed unconditionally. + PushDriver? _watchedDriver; + + /// Follows [NotificationManager.onPushDriverAttached], for a driver that + /// resolves after this widget is already mounted. See + /// [_PushPromptHostState._driverAttachedSubscription] for why a build with + /// no factory registered at `initState` needs this to ever notice one. + StreamSubscription? _driverAttachedSubscription; + + @override + void initState() { + super.initState(); + unawaited(_read()); + _watch(); + _driverAttachedSubscription = Notify.manager.onPushDriverAttached.listen( + _onDriverAttached, + onError: (Object error) => NotificationLog.error( + '[PushOffNotice] driver-attached stream failed: $error', + ), + ); + } + + @override + void dispose() { + unawaited(_driverAttachedSubscription?.cancel()); + _driverAttachedSubscription = null; + _cancelDriverWatchers(); + super.dispose(); + } + + /// Follows everything the driver reports about this device's state. + void _watch() { + final PushDriver? driver = Notify.manager.pushDriverOrNull; + if (driver == null) return; + + _watchDriver(driver); + } + + /// Reacts to a driver resolving or being replaced after this widget already + /// mounted: re-reads the advice, then follows [driver]'s own streams. + void _onDriverAttached(PushDriver driver) { + _watchDriver(driver); + unawaited(_read()); + } + + /// Wires [_watching] to [driver], first cancelling any subscription to a + /// previous one. See [_PushPromptHostState._watchDriver] for why this is + /// keyed on identity rather than unconditional. + void _watchDriver(PushDriver driver) { + if (identical(_watchedDriver, driver)) return; + + _cancelDriverWatchers(); + _watchedDriver = driver; + + // Both carry an `onError`: the driver pipes a failed platform read into + // these streams instead of swallowing it, and a subscription without a + // handler would hand that to the zone as an unhandled async error. + _watching.add( + driver.onPermissionChanged.listen( + (_) => unawaited(_read()), + onError: (Object error) => NotificationLog.error( + '[PushOffNotice] permission stream failed: $error', + ), + ), + ); + _watching.add( + driver.onIdentityChanged.listen( + (_) => unawaited(_read()), + onError: (Object error) => NotificationLog.error( + '[PushOffNotice] identity stream failed: $error', + ), + ), + ); + } + + /// Cancels every subscription in [_watching] and forgets [_watchedDriver]. + void _cancelDriverWatchers() { + for (final StreamSubscription subscription in _watching) { + unawaited(subscription.cancel()); + } + _watching.clear(); + _watchedDriver = null; + } + + /// Asks the package where this device stands. + /// + /// No decline timestamp is passed, and that is deliberate: `declinedAt` + /// only moves `advice.show`, which is the answer to "may I interrupt". This + /// marker never interrupts, so it reads [PushPromptAdvice.action] instead, + /// and a device stays marked whether or not the user turned the reminder + /// down. + /// + /// A platform read that throws answers "nothing to offer" rather than + /// raising on a lifecycle path. That hides the marker, which is the quieter + /// of the two wrong answers and matches the no-driver case it cannot be + /// told apart from: the preferences screen still states the device's + /// status honestly. + Future _read() async { + PushPromptAdvice advice; + + try { + advice = await Notify.manager.pushPromptAdvice(); + } catch (error) { + NotificationLog.warning( + '[PushOffNotice] push prompt advice failed: $error', + ); + + advice = _unreadableDevice; + } + + if (!mounted) return; + + setState(() => _advice = advice); + } + + @override + Widget build(BuildContext context) { + final PushPromptAdvice? advice = _advice; + if (advice == null || advice.action == PushPromptAction.none) { + return const SizedBox.shrink(); + } + + final String label = trans('notifications.push_prompt.shell_notice'); + + // Named for assistive technology on both forms, not just the compact one: + // the row's own label would otherwise be read as loose text with an + // unnamed tap target beside it. + return MergeSemantics( + child: Semantics( + label: trans('notifications.push_prompt.shell_notice_a11y'), + button: true, + child: WAnchor( + onTap: widget.onOpenPreferences, + child: WDiv( + className: pushOffNoticeRecipe( + variants: { + kPushOffNoticeDensityAxis: widget.compact + ? kPushOffNoticeDensityCompact + : kPushOffNoticeDensityFull, + }, + ), + children: [ + WIcon(_icon, className: pushOffNoticeIconClassName), + if (!widget.compact) + Expanded( + child: WText(label, className: pushOffNoticeLabelClassName), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/ui/components/push_prompt/push_prompt.preview.dart b/lib/src/ui/components/push_prompt/push_prompt.preview.dart new file mode 100644 index 0000000..ce3d70d --- /dev/null +++ b/lib/src/ui/components/push_prompt/push_prompt.preview.dart @@ -0,0 +1,101 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '../../../models/push_prompt_advice.dart'; +import '../../../models/push_subscription.dart' show PushReachability; +import 'push_prompt.dart'; + +/// Static preview for [PushPrompt] and [PushOffNotice]. +/// +/// Renders [PushPrompt] across every `(reachability, action)` pair it can +/// actually be handed (including both arms of a blocked device, which +/// `reachability` alone cannot distinguish), plus the declined and busy +/// variants of the `ask` state, and both [PushOffNotice] densities. +/// [PushPromptHost] is not previewed here: it reads a live +/// `Notify.manager.pushDriverOrNull`, which a static catalogue page has none +/// of, and [PushPrompt] already exercises every visual state it would render. +/// One preview class per file. +class PushPromptPreview extends StatelessWidget { + /// Creates a [PushPromptPreview]. + const PushPromptPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6 max-w-md', + children: [ + _labelled( + 'off / request', + const PushPrompt( + reachability: PushReachability.off, + action: PushPromptAction.request, + ), + ), + _labelled( + 'off / request, declined', + const PushPrompt( + reachability: PushReachability.off, + action: PushPromptAction.request, + declined: true, + ), + ), + _labelled( + 'off / request, busy', + const PushPrompt( + reachability: PushReachability.off, + action: PushPromptAction.request, + busy: true, + ), + ), + _labelled( + 'blocked / openSettings', + const PushPrompt( + reachability: PushReachability.blocked, + action: PushPromptAction.openSettings, + ), + ), + _labelled( + 'blocked / instructions', + const PushPrompt( + reachability: PushReachability.blocked, + action: PushPromptAction.instructions, + ), + ), + _labelled( + 'on / none', + const PushPrompt( + reachability: PushReachability.on, + action: PushPromptAction.none, + ), + ), + _labelled( + 'unavailable / none', + const PushPrompt( + reachability: PushReachability.unavailable, + action: PushPromptAction.none, + ), + ), + _labelled( + 'PushOffNotice, full', + PushOffNotice(onOpenPreferences: () {}), + ), + _labelled( + 'PushOffNotice, compact', + PushOffNotice(compact: true, onOpenPreferences: () {}), + ), + ], + ); + } + + /// A caption above [child], so the catalogue page reads which variant it is + /// looking at without opening this file. + Widget _labelled(String label, Widget child) { + return WDiv( + className: 'flex flex-col gap-2', + children: [ + WText(label, className: 'text-xs font-mono text-fg-muted'), + child, + ], + ); + } +} diff --git a/lib/src/ui/components/push_prompt/push_prompt.recipe.dart b/lib/src/ui/components/push_prompt/push_prompt.recipe.dart new file mode 100644 index 0000000..ca38925 --- /dev/null +++ b/lib/src/ui/components/push_prompt/push_prompt.recipe.dart @@ -0,0 +1,200 @@ +import 'package:magic/magic.dart'; + +/// The state axis key shared by the three push-prompt recipes. +/// +/// Its values are the four presentations the prompt has, which are NOT the +/// four `PushReachability` values: `off` splits into `ask` (the soft prompt) +/// and the compact enable row a resolved ask leaves behind, and both of those +/// carry the same container tokens. +const String kPushPromptStateAxis = 'state'; + +/// The `ask` presentation: the soft prompt, and the compact enable row. +const String kPushPromptStateAsk = 'ask'; + +/// The `blocked` presentation: the platform will not prompt again. +const String kPushPromptStateBlocked = 'blocked'; + +/// The `on` presentation: this device is reachable. +const String kPushPromptStateOn = 'on'; + +/// The `unavailable` presentation: this build has no push at all. +const String kPushPromptStateUnavailable = 'unavailable'; + +/// Builds the push-prompt container [WindRecipe]. +/// +/// Emission order: `base ++ state-variant ++ caller`. +/// +/// State -> token mapping: +/// - ask: `bg-surface-container` on `border-color-border` +/// - blocked: `bg-warning/10` (the warning role tinted, see below), the +/// same border as `ask` +/// - on: `bg-surface-container` on the hairline border +/// - unavailable: the same, quieter still +/// +/// The 17-key semantic alias contract (`design:sync`'s `_aliasMappings`) ships +/// `bg-warning` as a solid fill only; there is no `warning`-tinted container +/// alias the way `destructive` gets one (`bg-destructive-container`). The +/// opacity modifier is the tool this package already reaches for in that gap: +/// `notification_preferences_view.dart`'s `enabled:bg-primary/10` and +/// `notification_dropdown.dart`'s `unread:bg-primary/5` both tint an aliased +/// background this same way, and `bg-warning/10` is the same trick applied to +/// the warning role. +const WindRecipe pushPromptRecipe = WindRecipe( + base: 'w-full flex flex-row items-start gap-3 rounded-xl border p-4', + variants: { + kPushPromptStateAxis: { + kPushPromptStateAsk: 'border-color-border bg-surface-container', + kPushPromptStateBlocked: 'border-color-border bg-warning/10', + kPushPromptStateOn: 'border-color-border-subtle bg-surface-container', + kPushPromptStateUnavailable: + 'border-color-border-subtle bg-surface-container', + }, + }, + defaultVariants: {kPushPromptStateAxis: kPushPromptStateAsk}, +); + +/// Builds the glyph tile [WindRecipe] that leads every push-prompt row. +/// +/// Emission order: `base ++ state-variant ++ caller`. +/// +/// State -> token mapping: +/// - ask: `bg-primary-container`, the standard tinted-brand tile +/// - blocked: `bg-warning`, solid +/// - on: `bg-success`, solid +/// - unavailable: `bg-surface-container-high` +/// +/// `blocked` and `on` are solid rather than tinted for the same reason the +/// container above is not: `success` and `warning` carry no `-container` +/// alias to tint with. A solid tile paired with a `text-white` glyph +/// ([pushPromptIconRecipe]) is the pairing `toast.recipe.dart` already +/// establishes for these two roles in this package, so the tile follows the +/// same precedent rather than inventing a second one. +const WindRecipe pushPromptTileRecipe = WindRecipe( + base: 'size-8 shrink-0 flex items-center justify-center rounded-lg', + variants: { + kPushPromptStateAxis: { + kPushPromptStateAsk: 'bg-primary-container', + kPushPromptStateBlocked: 'bg-warning', + kPushPromptStateOn: 'bg-success', + kPushPromptStateUnavailable: 'bg-surface-container-high', + }, + }, + defaultVariants: {kPushPromptStateAxis: kPushPromptStateAsk}, +); + +/// Builds the glyph [WindRecipe] for the icon inside the tile. +/// +/// Separate from [pushPromptTileRecipe] because the colour rides the glyph and +/// the fill rides the tile, and Wind emits one className per widget. +/// +/// Emission order: `base ++ state-variant ++ caller`. +/// +/// State -> token mapping: +/// - ask: `text-primary` +/// - blocked: `text-white`, on the solid `bg-warning` tile +/// - on: `text-white`, on the solid `bg-success` tile +/// - unavailable: `text-fg-disabled` +/// +/// `text-white` rather than an aliased foreground: the 17-key contract has no +/// `text-on-warning` / `text-on-success` the way it has `text-on-destructive`, +/// and a fixed white glyph on a fixed solid fill needs no `dark:` pair, the +/// same choice `toast.recipe.dart` already made for the identical pairing. +const WindRecipe pushPromptIconRecipe = WindRecipe( + base: 'text-lg', + variants: { + kPushPromptStateAxis: { + kPushPromptStateAsk: 'text-primary', + kPushPromptStateBlocked: 'text-white', + kPushPromptStateOn: 'text-white', + kPushPromptStateUnavailable: 'text-fg-disabled', + }, + }, + defaultVariants: {kPushPromptStateAxis: kPushPromptStateAsk}, +); + +/// The "enable push" button's className. +/// +/// `border-transparent` reserves the same box `focus:border-primary` fills, +/// so the focus state adds no layout shift; `hover:`/`focus:` share the +/// tinted brand surface [pushPromptTileRecipe] already uses for the `ask` +/// tile. `px-4 py-3`, not a smaller box: on a phone this may be the only +/// control that fixes a device push cannot reach, and 4px of vertical padding +/// around `text-sm` is roughly a 30px target against a 44dp floor. +const String pushPromptEnableButtonClassName = + 'rounded-md border border-transparent px-4 py-3 text-sm font-medium ' + 'text-primary transition-colors hover:bg-primary-container ' + 'focus:border-primary focus:bg-primary-container'; + +/// The decline ("not now") button's className. +/// +/// The same `px-4 py-3` box as [pushPromptEnableButtonClassName] beside it, +/// deliberately: the two sit on one row, so a smaller box here renders two +/// controls of visibly different height, and the 44dp touch floor the enable +/// button's padding exists for applies to a decline made on a phone too. +const String pushPromptDeclineButtonClassName = + 'rounded-md border border-transparent px-4 py-3 text-sm font-medium ' + 'text-fg-muted transition-colors hover:bg-surface-container ' + 'hover:text-fg focus:border-color-border focus:bg-surface-container ' + 'focus:text-fg'; + +/// The single-button and two-button actions row shared by every action state. +/// +/// `wrap`, not `flex-row`: the `ask` state offers two buttons, and a locale +/// whose labels run longer than English can overflow a fixed-width row before +/// wrapping ever gets a chance to fire. A wrap flows the second button onto +/// its own line instead. The single-button states share it deliberately: the +/// same long-label pressure applies to one button in a narrow card, and a +/// second token here would be a second thing to get wrong. +const String pushPromptActionsClassName = 'wrap items-center gap-4'; + +/// The density axis key for the shell notice. +/// +/// Its two values are two SHELL SHAPES a host may need this marker in, not two +/// sizes of one thing: a sidebar column has room for a sentence, a mobile top +/// bar does not. +const String kPushOffNoticeDensityAxis = 'density'; + +/// The sidebar form: a glyph and a line, sized like a nav row. +const String kPushOffNoticeDensityFull = 'full'; + +/// The mobile top-bar form: the glyph alone, sized like an account avatar. +const String kPushOffNoticeDensityCompact = 'compact'; + +/// Builds the shell notice's [WindRecipe]. +/// +/// Emission order: `base ++ density-variant ++ caller`. +/// +/// The base carries no colour of its own; the warning lives entirely in the +/// glyph ([pushOffNoticeIconClassName]) so the marker reads as one of a +/// shell's secondary controls rather than as an alert. Both variants reuse +/// the `hover:bg-surface-container` affordance an app's other shell controls +/// typically carry, and both own their outer spacing, so a hidden notice +/// costs no layout at all. +/// +/// Density -> token mapping: +/// - full: a full-width row inset to match a sidebar's `px-3` nav column +/// - compact: a 36px round tap target, an account avatar's usual footprint +const WindRecipe pushOffNoticeRecipe = WindRecipe( + base: 'flex flex-row items-center rounded-md hover:bg-surface-container', + variants: { + kPushOffNoticeDensityAxis: { + kPushOffNoticeDensityFull: 'w-full gap-2 mx-3 mb-2 px-2 py-2', + kPushOffNoticeDensityCompact: + 'w-9 h-9 shrink-0 justify-center rounded-full', + }, + }, + defaultVariants: {kPushOffNoticeDensityAxis: kPushOffNoticeDensityFull}, +); + +/// The shell notice's glyph className. +/// +/// `text-fg-muted` rather than a warning tint: the 17-key alias contract has +/// no `text-warning` (only `bg-warning`, a background-only role; see +/// [pushPromptIconRecipe]), and pairing a bare glyph with no background tile +/// against `text-white` would be illegible on a transparent surface. A host +/// wanting a stronger signal here wraps this marker in its own tinted tile; +/// this package does not invent an alias the shared contract does not define. +const String pushOffNoticeIconClassName = 'text-[16px] text-fg-muted'; + +/// The shell notice's label className, matching a sidebar's secondary rows. +const String pushOffNoticeLabelClassName = 'truncate text-xs text-fg-muted'; diff --git a/test/push_identity_reconciled_test.dart b/test/push_identity_reconciled_test.dart new file mode 100644 index 0000000..4a000a1 --- /dev/null +++ b/test/push_identity_reconciled_test.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_notifications/magic_notifications.dart'; + +import 'test_helper.dart'; + +/// A push driver minimal enough to drive one reconcile pass at a time. +/// +/// Modeled on `_RecordingPushDriver` in `notification_manager_reconcile_test.dart`: +/// the same held-read mechanism ([readGate]) is what lets a test park a pass +/// mid-read and change the intent underneath it, which is the race +/// [onPushIdentityReconciled] has to survive. +class _FakePushDriver extends PushDriver { + _FakePushDriver({String? subscribedAs, this.failLogin = false}) + : _externalId = subscribedAs; + + /// Whether [login] throws, standing in for a call the SDK refused. + final bool failLogin; + + /// The external id the device currently reports, mutated the way the SDK + /// mutates its local user: immediately, before any server round trip. + String? _externalId; + + /// When set, the NEXT [currentExternalId] parks on it after reading the + /// device, so a test can hold a reconcile pass open and drive the intent + /// into a different state before letting it finish. + Completer? readGate; + + final StreamController _received = + StreamController.broadcast(); + final StreamController _clicked = + StreamController.broadcast(); + final StreamController _identity = + StreamController.broadcast(); + + @override + String get name => 'fake'; + + @override + bool get isSupported => true; + + @override + Future permissionState() async => + PushPermissionState.authorized; + + @override + bool get isOptedIn => true; + + @override + Future initialize(Map config) async {} + + @override + Future login(String externalId) async { + if (failLogin) throw StateError('the SDK refused the identity call'); + _externalId = externalId; + } + + @override + Future logout() async { + _externalId = null; + } + + @override + Future currentExternalId() async { + final String? reported = _externalId; + + final Completer? gate = readGate; + if (gate != null) { + readGate = null; + await gate.future; + } + + return reported; + } + + @override + Future currentSubscriptionId() async => 'sub-1'; + + @override + Future requestPermission() async => true; + + @override + Future optIn() async {} + + @override + Future optOut() async {} + + @override + Future setTags(Map tags) async {} + + @override + Future removeTag(String key) async {} + + @override + Stream get onNotificationReceived => _received.stream; + + @override + Stream get onNotificationClicked => _clicked.stream; + + @override + Stream get onPermissionChanged => + const Stream.empty(); + + @override + Stream get onIdentityChanged => _identity.stream; + + /// Closes the three controllers a test opened. + void dispose() { + _received.close(); + _clicked.close(); + _identity.close(); + } +} + +void main() { + late NotificationManager manager; + + setUpAll(() async { + await initMagicForTests(); + }); + + setUp(() { + manager = NotificationManager(); + manager.forgetDrivers(); + Http.fake({ + 'notifications': Http.response({'data': []}), + }); + }); + + tearDown(() { + manager.forgetDrivers(); + }); + + /// Registers [driver] the way a consumer registers one, through the + /// registry and with no provider booted. + _FakePushDriver use(_FakePushDriver driver) { + Notify.extend(driver.name, () => driver); + addTearDown(Notify.forgetDrivers); + addTearDown(driver.dispose); + + return driver; + } + + group('onPushIdentityReconciled', () { + test('a converging pass emits the intent, converged and no error', + () async { + use(_FakePushDriver()); + await manager.want('user_1'); + + final List events = []; + manager.onPushIdentityReconciled.listen(events.add); + + await manager.reconcilePushIdentity(); + await pumpEventQueue(); + + expect(events, hasLength(1)); + expect(events.single.intent, 'user_1'); + expect(events.single.converged, isTrue); + expect(events.single.error, isNull); + }); + + test('a pass whose login throws still emits, carrying the error', () async { + use(_FakePushDriver(failLogin: true)); + await manager.want('user_1'); + + final List events = []; + manager.onPushIdentityReconciled.listen(events.add); + + await manager.reconcilePushIdentity(); + await pumpEventQueue(); + + expect(events, hasLength(1)); + expect(events.single.intent, 'user_1'); + expect(events.single.converged, isFalse); + expect(events.single.error, isNotNull); + }); + + test( + 'a pass whose intent moved before it finished emits nothing for the ' + 'stale intent', () async { + final _FakePushDriver driver = use(_FakePushDriver()); + await manager.want('user_1'); + + final Completer gate = Completer(); + driver.readGate = gate; + + final List events = []; + manager.onPushIdentityReconciled.listen(events.add); + + final Future pass = manager.reconcilePushIdentity(); + await pumpEventQueue(); + + // The device changed hands while the pass was parked on its read, so + // whatever this pass concludes about "user_1" is stale by the time it + // returns. + await manager.want('user_2'); + gate.complete(); + await pass; + + expect(events, isEmpty); + }); + + test('a pass with no driver emits nothing', () async { + final List events = []; + manager.onPushIdentityReconciled.listen(events.add); + + await manager.want('user_1'); + await manager.reconcilePushIdentity(); + + expect(events, isEmpty); + }); + + test('two joined callers produce one event', () async { + final _FakePushDriver driver = use(_FakePushDriver()); + await manager.want('user_1'); + + final Completer gate = Completer(); + driver.readGate = gate; + + final List events = []; + manager.onPushIdentityReconciled.listen(events.add); + + final Future first = manager.reconcilePushIdentity(); + await pumpEventQueue(); + final Future second = manager.reconcilePushIdentity(); + await pumpEventQueue(); + + gate.complete(); + await Future.wait(>[first, second]); + + expect(events, hasLength(1)); + expect(events.single.intent, 'user_1'); + expect(events.single.converged, isTrue); + }); + }); +} diff --git a/test/ui/components/push_prompt_test.dart b/test/ui/components/push_prompt_test.dart new file mode 100644 index 0000000..8055df2 --- /dev/null +++ b/test/ui/components/push_prompt_test.dart @@ -0,0 +1,1214 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_notifications/src/drivers/push/push_driver.dart'; +import 'package:magic_notifications/src/facades/notify.dart'; +import 'package:magic_notifications/src/models/push_prompt_advice.dart'; +import 'package:magic_notifications/src/models/push_subscription.dart'; +import 'package:magic_notifications/src/notification_manager.dart'; +import 'package:magic_notifications/src/ui/components/push_prompt/push_prompt.dart'; + +import '../../test_helper.dart'; + +/// The vault key every test in this file gives [PushPromptHost]. +/// +/// A host supplies its own key; this is a test's stand-in for one. +const String _declinedVaultKey = 'test.push_prompt_declined'; + +/// Feeds the translator a literal map so the prompt lays out real labels. +class _MapTranslationLoader implements TranslationLoader { + const _MapTranslationLoader(this.sentences); + + final Map sentences; + + @override + Future> load(Locale locale) async => sentences; +} + +/// The sentences every test in this file lays out with. +const Map _sentences = { + 'notifications.push_prompt.unavailable_body': + 'Push is not available on this build.', + 'notifications.push_prompt.on_body': + 'This device can receive push notifications.', + 'notifications.push_prompt.blocked_title': 'Notifications are blocked', + 'notifications.push_prompt.blocked_body_settings': + 'Turn notifications back on in Settings.', + 'notifications.push_prompt.blocked_body_web': + 'Open the padlock icon in your browser bar to allow notifications.', + 'notifications.push_prompt.blocked_body_ios': + 'Open Settings > Notifications to allow notifications.', + 'notifications.push_prompt.blocked_body_android': + 'Open the app info screen to allow notifications.', + 'notifications.push_prompt.declined_body': 'You turned off this reminder.', + 'notifications.push_prompt.ask_title': 'Turn on notifications', + 'notifications.push_prompt.ask_body': + 'Get notified the moment something needs your attention.', + 'notifications.push_prompt.open_settings': 'Open settings', + 'notifications.push_prompt.enable': 'Enable', + 'notifications.push_prompt.not_now': 'Not now', + 'notifications.push_prompt.shell_notice': 'Push is off', + 'notifications.push_prompt.shell_notice_a11y': 'Push notifications are off', +}; + +/// A push driver double that records every permission request it is asked +/// for. +/// +/// Contract inheritance rather than a mock package, matching +/// `notification_manager_reconcile_test.dart`'s `_RecordingPushDriver`. The +/// COUNT is the subject: the whole point of a soft prompt is that a decline +/// never reaches the platform, because the OS prompt fires once per install +/// and a declined one cannot be re-asked. +class _RecordingPushDriver extends PushDriver { + _RecordingPushDriver({ + this.permission = PushPermissionState.notDetermined, + this.optedIn = false, + this.subscriptionId, + this.opensPlatformSettings = false, + }); + + /// The permission the platform reports. + final PushPermissionState permission; + + /// Whether this fake device is opted in. + final bool optedIn; + + /// The subscription id the platform holds, or null for none. + final String? subscriptionId; + + /// Whether a request on a DENIED device routes the user to the platform + /// setting, which is the mobile `fallback_to_settings` capability. False is + /// the browser, where no API opens the site settings panel from a page. + final bool opensPlatformSettings; + + /// How many times [requestPermission] was called. + int permissionRequests = 0; + + final StreamController _received = + StreamController.broadcast(); + final StreamController _clicked = + StreamController.broadcast(); + + @override + bool get canOpenPlatformSettings => opensPlatformSettings; + + @override + String get name => 'test'; + + @override + bool get isSupported => true; + + @override + bool get isOptedIn => optedIn; + + @override + Future permissionState() async => permission; + + @override + Future initialize(Map config) async {} + + @override + Future login(String externalId) async {} + + @override + Future logout() async {} + + @override + Future currentExternalId() async => 'user_u1'; + + @override + Future currentSubscriptionId() async => subscriptionId; + + @override + Future requestPermission() async { + permissionRequests++; + + return true; + } + + @override + Future optIn() async {} + + @override + Future optOut() async {} + + @override + Future setTags(Map tags) async {} + + @override + Future removeTag(String key) async {} + + @override + Stream get onNotificationReceived => _received.stream; + + @override + Stream get onNotificationClicked => _clicked.stream; + + @override + Stream get onPermissionChanged => + const Stream.empty(); + + @override + Stream get onIdentityChanged => + const Stream.empty(); + + /// Closes the internal controllers. Registered through `addTearDown`. + Future dispose() async { + await _received.close(); + await _clicked.close(); + } +} + +/// A [_RecordingPushDriver] whose [requestPermission] throws. +/// +/// Reproduces a platform SDK failure (a denied browser permission API, a +/// missing native module) so the row's boundary handling can be exercised +/// without a real device. +class _ThrowingRequestPushDriver extends _RecordingPushDriver { + /// How many times [permissionState] was read; used to prove the row + /// re-read the platform after the throw instead of getting stuck. + int permissionStateReads = 0; + + @override + Future permissionState() async { + permissionStateReads++; + + return super.permissionState(); + } + + @override + Future requestPermission() async { + permissionRequests++; + + throw StateError('push permission request failed'); + } +} + +/// A [_RecordingPushDriver] whose [permissionState] always throws. +/// +/// Reproduces a platform-channel failure on the very FIRST read, before any +/// tap: `reachability()` (the base class method the host's advice read +/// reaches) calls [permissionState] unconditionally, so a throw here +/// reproduces the boot-time defect rather than the enable-button one +/// [_ThrowingRequestPushDriver] covers. +class _ThrowingReachabilityPushDriver extends _RecordingPushDriver { + @override + Future permissionState() async { + throw StateError('permission state read failed'); + } +} + +/// A driver whose reported state can change and whose change streams a test +/// can drive. +/// +/// [_RecordingPushDriver] answers `const Stream.empty()` for both change +/// streams, which is exactly the condition a widget that never subscribes +/// cannot be told apart from. This double carries real broadcast controllers +/// so a grant that arrives OUT OF BAND can be delivered, which is how a grant +/// normally arrives: `requestPermission` on an already-denied device opens +/// the platform settings page rather than a dialog, so the permission +/// changes while the app is backgrounded. +class _LivePushDriver extends _RecordingPushDriver { + _LivePushDriver({ + this.permissionNow = PushPermissionState.notDetermined, + this.optedInNow = false, + this.subscriptionIdNow, + }); + + /// The permission the platform reports right now. + PushPermissionState permissionNow; + + /// Whether this fake device is opted in right now. + bool optedInNow; + + /// The subscription id the platform holds right now, or null for none. + String? subscriptionIdNow; + + final StreamController _permissions = + StreamController.broadcast(); + + final StreamController _identities = + StreamController.broadcast(); + + @override + Future permissionState() async => permissionNow; + + @override + bool get isOptedIn => optedInNow; + + @override + Future currentSubscriptionId() async => subscriptionIdNow; + + @override + Stream get onPermissionChanged => _permissions.stream; + + @override + Stream get onIdentityChanged => _identities.stream; + + /// Turns this device into a subscribed one and announces it the way the SDK + /// does: the permission first, then the subscription landing behind it. + void grantAndSubscribe(String subscriptionId) { + permissionNow = PushPermissionState.authorized; + optedInNow = true; + subscriptionIdNow = subscriptionId; + _permissions.add(PushPermissionState.authorized); + _identities.add(const PushIdentityChange(externalId: 'user_u1')); + } + + /// Pushes an error onto the permission stream, the way the real driver does. + void failPermissionRead() => + _permissions.addError(StateError('permission channel unavailable')); + + @override + Future dispose() async { + await _permissions.close(); + await _identities.close(); + await super.dispose(); + } +} + +/// A [_LivePushDriver] whose [permissionState] can be held open, so a test can +/// control exactly when a read that started earlier answers. +/// +/// The generation-counter fix this double exercises is about ORDER, not +/// content: [holdNextPermissionRead] does not change what the platform would +/// have answered, only when the caller finds out. +class _RacePushDriver extends _LivePushDriver { + Completer? _held; + + /// Makes the NEXT call to [permissionState] wait on [completer] instead of + /// resolving immediately. + void holdNextPermissionRead(Completer completer) { + _held = completer; + } + + /// Fires the permission-changed stream without moving [permissionNow], the + /// way the widget's own listener has to re-read to find out what changed: + /// the announcement carries no payload the widget trusts on its own. + void announcePermissionChanged() => _permissions.add(permissionNow); + + @override + Future permissionState() async { + final Completer? held = _held; + if (held != null) { + _held = null; + + return held.future; + } + + return super.permissionState(); + } +} + +/// A [MagicVaultService] whose [put] throws, reproducing secure storage being +/// unavailable (a browser with no storage backend, a locked keychain). +class _ThrowingPutVaultService extends MagicVaultService { + _ThrowingPutVaultService() : super.forTesting(); + + /// How many times [put] was attempted. + int putAttempts = 0; + + @override + Future put(String key, String value) async { + putAttempts++; + + throw MagicVaultException('vault write failed', 'disk full'); + } + + @override + Future get(String key) async => null; +} + +/// A [MagicVaultService] whose [put] waits for [release] before storing, so a +/// test can run a read while a decline's write is still in flight. +class _HeldPutVaultService extends MagicVaultService { + _HeldPutVaultService() : super.forTesting(); + + final Completer release = Completer(); + final Map _stored = {}; + + @override + Future put(String key, String value) async { + await release.future; + _stored[key] = value; + } + + @override + Future get(String key) async => _stored[key]; +} + +/// Every reading `pushPromptAdvice` can actually produce, as the pair the row +/// renders from. +/// +/// Reachability alone does not name a presentation: `blocked` splits on +/// whether this platform can route the tap back to a setting. +const List<(PushReachability, PushPromptAction)> _everyState = + <(PushReachability, PushPromptAction)>[ + (PushReachability.off, PushPromptAction.request), + (PushReachability.blocked, PushPromptAction.openSettings), + (PushReachability.blocked, PushPromptAction.instructions), + (PushReachability.on, PushPromptAction.none), + (PushReachability.unavailable, PushPromptAction.none), +]; + +void main() { + setUpAll(() async { + await initMagicForTests(); + + Translator.instance.setLoader(const _MapTranslationLoader(_sentences)); + await Translator.instance.load(const Locale('en')); + }); + + setUp(() { + NotificationManager().forgetDrivers(); + // Every test here mounts a widget that reads or writes the vault on + // mount (`PushPromptHost`), so this is faked globally rather than per + // group. + Vault.fake(); + }); + + tearDown(() { + NotificationManager().forgetDrivers(); + Vault.unfake(); + }); + + /// Registers [driver] as the app's push rail, the way a consumer swaps one. + void usePushDriver(_RecordingPushDriver driver) { + Notify.extend(driver.name, () => driver); + addTearDown(Notify.forgetDrivers); + addTearDown(driver.dispose); + } + + /// Wraps [widget] in a [MaterialApp] with a default [WindTheme]. + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: SingleChildScrollView(child: widget)), + ), + ); + } + + // --------------------------------------------------------------------------- + // The blocked state: an instruction, never a control + // --------------------------------------------------------------------------- + + group('the blocked state', () { + testWidgets('renders the instruction row and no control at all', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + const PushPrompt( + reachability: PushReachability.blocked, + action: PushPromptAction.instructions, + onEnable: null, + ), + ), + ); + + // A blocked permission cannot be re-prompted from inside the app. + // Where nothing can route the tap either, a control is one that does + // nothing, so the row says where the switch actually lives instead. + expect(find.byKey(PushPrompt.blockedInstructionKey), findsOneWidget); + expect(find.byType(WButton), findsNothing); + expect( + find.text(trans('notifications.push_prompt.enable')), + findsNothing, + ); + }); + + testWidgets( + 'canOpenPlatformSettings offers a real action instead of an instruction', + (tester) async { + final _RecordingPushDriver driver = _RecordingPushDriver( + permission: PushPermissionState.denied, + opensPlatformSettings: true, + ); + usePushDriver(driver); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.open_settings')), + findsOneWidget, + ); + expect(find.byKey(PushPrompt.blockedInstructionKey), findsNothing); + + await tester.tap( + find.text(trans('notifications.push_prompt.open_settings')), + ); + await tester.pumpAndSettle(); + + // The same call the enable control makes: the driver hands it + // `canOpenPlatformSettings`, and the SDK turns it into the settings + // page. + expect(driver.permissionRequests, 1); + }, + ); + + testWidgets('the instruction is real copy, not a raw key', (tester) async { + await tester.pumpWidget( + wrap( + const PushPrompt( + reachability: PushReachability.blocked, + action: PushPromptAction.instructions, + ), + ), + ); + + final WText instruction = tester.widget( + find.descendant( + of: find.byKey(PushPrompt.blockedInstructionKey), + matching: find.byType(WText), + ), + ); + + expect(instruction.data, isNotEmpty); + expect(instruction.data, isNot(startsWith('notifications.'))); + }); + }); + + // --------------------------------------------------------------------------- + // The soft prompt: a decline never reaches the platform + // --------------------------------------------------------------------------- + + group('the soft prompt', () { + testWidgets('a decline does not call requestPermission', (tester) async { + final _RecordingPushDriver driver = _RecordingPushDriver(); + usePushDriver(driver); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(trans('notifications.push_prompt.not_now'))); + await tester.pumpAndSettle(); + + // The OS prompt fires once per install; burning it on a decline leaves + // the user with no way back to push at all. + expect(driver.permissionRequests, 0); + }); + + testWidgets('a decline is recorded and leaves an explicit enable control', ( + tester, + ) async { + usePushDriver(_RecordingPushDriver()); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(trans('notifications.push_prompt.not_now'))); + await tester.pumpAndSettle(); + + expect(await Vault.get(_declinedVaultKey), isNotNull); + expect( + find.text(trans('notifications.push_prompt.not_now')), + findsNothing, + ); + expect( + find.text(trans('notifications.push_prompt.enable')), + findsOneWidget, + ); + }); + + testWidgets('the explicit enable control does reach the platform', ( + tester, + ) async { + final _RecordingPushDriver driver = _RecordingPushDriver(); + usePushDriver(driver); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(trans('notifications.push_prompt.enable'))); + await tester.pumpAndSettle(); + + expect(driver.permissionRequests, 1); + }); + }); + + // --------------------------------------------------------------------------- + // A stale read must not undo a decline that landed after it started + // --------------------------------------------------------------------------- + + group('a read overtaken by a decline', () { + testWidgets( + 'a read that started before the decline but answers after it does not ' + 'reopen the ask row', + (tester) async { + final _RacePushDriver driver = _RacePushDriver(); + usePushDriver(driver); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.ask_title')), + findsOneWidget, + ); + + // 1. Hold the platform read the next `_read()` will make, then fire + // that re-read the way a permission event does. Its vault read + // lands with `declinedAt: null`, because the decline below has + // not written anything yet. + final Completer heldRead = + Completer(); + driver.holdNextPermissionRead(heldRead); + driver.announcePermissionChanged(); + await tester.pump(); + + // 2. The decline runs to completion while the read above is still + // in flight: it writes the vault, re-reads (its own platform read + // is no longer held), and lands the compact row. + await tester.tap( + find.text(trans('notifications.push_prompt.not_now')), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.enable')), + findsOneWidget, + reason: 'the decline landed first and must be on screen', + ); + expect( + find.text(trans('notifications.push_prompt.not_now')), + findsNothing, + ); + + // 3. The held read now answers, carrying the pre-decline state. A + // generation counter must drop it rather than let it undo the + // decline that finished while it was still in flight. + heldRead.complete(PushPermissionState.notDetermined); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.enable')), + findsOneWidget, + reason: 'the stale read must not reopen the ask row', + ); + expect( + find.text(trans('notifications.push_prompt.not_now')), + findsNothing, + ); + }, + ); + + testWidgets( + 'a read that starts while the decline is still writing does not drop ' + 'the decline', + (tester) async { + final _RacePushDriver driver = _RacePushDriver(); + usePushDriver(driver); + final _HeldPutVaultService heldVault = _HeldPutVaultService(); + Magic.app.setInstance('vault', heldVault); + addTearDown(() => Magic.app.removeInstance('vault')); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + // 1. The decline starts and its vault write is held open. + await tester.tap( + find.text(trans('notifications.push_prompt.not_now')), + ); + await tester.pump(); + + // 2. A permission event re-reads while that write is in flight; the + // vault still holds nothing, so this read sees no decline. + driver.announcePermissionChanged(); + await tester.pumpAndSettle(); + + // 3. The write lands. The decline must still win: it is the newer + // fact, and the read that started before it landed is the stale one. + heldVault.release.complete(); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.enable')), + findsOneWidget, + ); + expect( + find.text(trans('notifications.push_prompt.not_now')), + findsNothing, + reason: 'the decline landed after the read and must be on screen', + ); + }, + ); + }); + + // --------------------------------------------------------------------------- + // The reminder cadence: the host owns the decline TIMESTAMP + // --------------------------------------------------------------------------- + + group('the reminder cadence', () { + /// The interval these cases drive, in hours. Deliberately not read from + /// any config default: an assertion that took its expectation from the + /// same value the code reads would pass for any shipped number. + const int repromptHours = 20; + + setUp(() { + Config.set(NotificationManager.repromptAfterHoursKey, repromptHours); + }); + + tearDown(() { + Config.forget(NotificationManager.repromptAfterHoursKey); + }); + + /// Records a decline [ago] before now, the way the host persists one. + Future declinedAgo(Duration ago) async { + await Vault.put( + _declinedVaultKey, + DateTime.now().toUtc().subtract(ago).toIso8601String(), + ); + } + + testWidgets('a decline older than the interval is asked again', ( + tester, + ) async { + usePushDriver(_RecordingPushDriver()); + await declinedAgo(const Duration(hours: repromptHours + 1)); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + // The full soft prompt, decline control and all: the interval has + // elapsed, so this device is due to be asked again rather than left + // with the compact row a fresh decline leaves behind. + expect( + find.text(trans('notifications.push_prompt.ask_title')), + findsOneWidget, + ); + expect( + find.text(trans('notifications.push_prompt.not_now')), + findsOneWidget, + ); + }); + + testWidgets('a decline younger than the interval is not', (tester) async { + usePushDriver(_RecordingPushDriver()); + await declinedAgo(const Duration(hours: repromptHours - 1)); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.ask_title')), + findsNothing, + ); + expect( + find.text(trans('notifications.push_prompt.not_now')), + findsNothing, + ); + expect( + find.text(trans('notifications.push_prompt.enable')), + findsOneWidget, + ); + }); + + testWidgets( + 'a value written in some other shape reads as never declined, not ' + 'migrated', + (tester) async { + // This widget only reads an ISO-8601 instant; a value some other + // build wrote in a different shape (a bare `'1'`, say) is the HOST's + // migration to make before it ever hands this widget the key, not + // this widget's to repair. See [PushPromptHost.declinedVaultKey]. + usePushDriver(_RecordingPushDriver()); + await Vault.put(_declinedVaultKey, '1'); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.ask_title')), + findsOneWidget, + ); + expect( + find.text(trans('notifications.push_prompt.not_now')), + findsOneWidget, + ); + + // And it is NOT rewritten: this widget only ever reads a timestamp. + expect(await Vault.get(_declinedVaultKey), '1'); + }, + ); + + testWidgets('a fresh decline is recorded as a timestamp', (tester) async { + usePushDriver(_RecordingPushDriver()); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(trans('notifications.push_prompt.not_now'))); + await tester.pumpAndSettle(); + + final String? stored = await Vault.get(_declinedVaultKey); + + expect(DateTime.tryParse(stored ?? ''), isNotNull); + }); + }); + + // --------------------------------------------------------------------------- + // Two dropped futures on a boundary + // --------------------------------------------------------------------------- + + group('a failing vault write on decline', () { + testWidgets( + 'is handled, not an unhandled async error, and the decline is not ' + 'reported as having worked', + (tester) async { + usePushDriver(_RecordingPushDriver()); + final _ThrowingPutVaultService throwingVault = + _ThrowingPutVaultService(); + Magic.app.setInstance('vault', throwingVault); + addTearDown(() => Magic.app.removeInstance('vault')); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.text(trans('notifications.push_prompt.not_now')), + ); + await tester.pumpAndSettle(); + + // The throwing put() must not escape as an unhandled async error. + expect(tester.takeException(), isNull); + expect(throwingVault.putAttempts, 1); + + // The decline never landed, so the row must not claim it did. + expect( + find.text(trans('notifications.push_prompt.not_now')), + findsOneWidget, + ); + expect( + find.text(trans('notifications.push_prompt.ask_title')), + findsOneWidget, + ); + }, + ); + }); + + group('an initial reachability read that throws', () { + testWidgets( + 'is handled, not an unhandled async error, and the row does not stay ' + 'blank', + (tester) async { + usePushDriver(_ThrowingReachabilityPushDriver()); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(PushPrompt), findsOneWidget); + }, + ); + }); + + group('a failing requestPushPermission on enable', () { + testWidgets('is handled and the row still refreshes', (tester) async { + final _ThrowingRequestPushDriver driver = _ThrowingRequestPushDriver(); + usePushDriver(driver); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + final int readsBeforeTap = driver.permissionStateReads; + + await tester.tap(find.text(trans('notifications.push_prompt.enable'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(driver.permissionRequests, 1); + + // The row must have re-read the platform after the throw, proving it + // did not get stuck on the spinner. + expect(driver.permissionStateReads, greaterThan(readsBeforeTap)); + }); + }); + + // --------------------------------------------------------------------------- + // A config key that gates the whole prompt + // --------------------------------------------------------------------------- + + group('notifications.soft_prompt.enabled', () { + tearDown(() => Config.forget(NotificationManager.softPromptEnabledKey)); + + testWidgets('false renders no prompt at all', (tester) async { + Config.set(NotificationManager.softPromptEnabledKey, false); + usePushDriver(_RecordingPushDriver()); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect(find.byType(PushPrompt), findsNothing); + }); + + testWidgets('true (the default) still renders the prompt', ( + tester, + ) async { + Config.set(NotificationManager.softPromptEnabledKey, true); + usePushDriver(_RecordingPushDriver()); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect(find.byType(PushPrompt), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // The host reads reachability rather than declaring it + // --------------------------------------------------------------------------- + + group('the host', () { + testWidgets('renders the blocked row for a denied permission', ( + tester, + ) async { + usePushDriver( + _RecordingPushDriver(permission: PushPermissionState.denied), + ); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(PushPrompt.blockedInstructionKey), findsOneWidget); + }); + + testWidgets('renders the on row for a subscribed device', (tester) async { + usePushDriver( + _RecordingPushDriver( + permission: PushPermissionState.authorized, + optedIn: true, + subscriptionId: 'sub-1', + ), + ); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.on_body')), + findsOneWidget, + ); + expect(find.byType(WButton), findsNothing); + }); + + testWidgets('a grant arriving out of band clears the ask row', ( + tester, + ) async { + final _LivePushDriver driver = _LivePushDriver(); + usePushDriver(driver); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.ask_title')), + findsOneWidget, + reason: 'a device with no decision yet is asked', + ); + + driver.grantAndSubscribe('sub-1'); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.on_body')), + findsOneWidget, + ); + expect( + find.text(trans('notifications.push_prompt.ask_title')), + findsNothing, + ); + }); + + testWidgets('a failed permission read is logged, not thrown at the zone', ( + tester, + ) async { + final FakeLogManager log = Log.fake(); + final _LivePushDriver driver = _LivePushDriver( + permissionNow: PushPermissionState.authorized, + optedInNow: true, + subscriptionIdNow: 'sub-1', + ); + usePushDriver(driver); + + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + driver.failPermissionRead(); + await tester.pumpAndSettle(); + + expect( + log.entries + .where( + (FakeLogEntry entry) => entry.message.contains( + '[PushPromptHost] permission stream failed', + ), + ) + .length, + 1, + ); + expect( + find.text(trans('notifications.push_prompt.on_body')), + findsOneWidget, + ); + }); + + testWidgets('renders the unavailable row when the build has no driver', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.unavailable_body')), + findsOneWidget, + ); + }); + }); + + // --------------------------------------------------------------------------- + // A driver that resolves after the widget already mounted + // --------------------------------------------------------------------------- + + group('a driver attached after mount', () { + testWidgets( + 'PushPromptHost re-reads on onPushDriverAttached and then follows ' + 'the new driver own streams', + (tester) async { + await tester.pumpWidget( + wrap(const PushPromptHost(declinedVaultKey: _declinedVaultKey)), + ); + await tester.pumpAndSettle(); + + // No driver at mount: the row reads unavailable, not stuck blank. + expect( + find.text(trans('notifications.push_prompt.unavailable_body')), + findsOneWidget, + ); + + final _LivePushDriver driver = _LivePushDriver(); + addTearDown(driver.dispose); + Notify.manager.setPushDriver(driver); + await tester.pumpAndSettle(); + + // The late attachment is picked up without a remount. + expect( + find.text(trans('notifications.push_prompt.ask_title')), + findsOneWidget, + ); + + // Proves the widget wired itself to THIS driver's own streams, not + // only the one (absent) at initState. + driver.grantAndSubscribe('sub-1'); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.on_body')), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'PushOffNotice re-reads on onPushDriverAttached and then follows the ' + 'new driver own streams', + (tester) async { + await tester.pumpWidget(wrap(PushOffNotice(onOpenPreferences: () {}))); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.shell_notice')), + findsNothing, + ); + + final _LivePushDriver driver = _LivePushDriver(); + addTearDown(driver.dispose); + Notify.manager.setPushDriver(driver); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.shell_notice')), + findsOneWidget, + ); + + driver.grantAndSubscribe('sub-1'); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.shell_notice')), + findsNothing, + ); + }, + ); + }); + + // --------------------------------------------------------------------------- + // The shell notice: push being off is visible OUTSIDE the settings screen + // --------------------------------------------------------------------------- + + group('the shell notice', () { + testWidgets('warns while the permission has not been granted', ( + tester, + ) async { + usePushDriver(_RecordingPushDriver()); + + await tester.pumpWidget(wrap(PushOffNotice(onOpenPreferences: () {}))); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.shell_notice')), + findsOneWidget, + ); + }); + + testWidgets('warns while the permission is blocked', (tester) async { + usePushDriver( + _RecordingPushDriver(permission: PushPermissionState.denied), + ); + + await tester.pumpWidget(wrap(PushOffNotice(onOpenPreferences: () {}))); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.shell_notice')), + findsOneWidget, + ); + }); + + testWidgets('says nothing when push can reach this device', (tester) async { + usePushDriver( + _RecordingPushDriver( + permission: PushPermissionState.authorized, + optedIn: true, + subscriptionId: 'sub-1', + ), + ); + + await tester.pumpWidget(wrap(PushOffNotice(onOpenPreferences: () {}))); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.shell_notice')), + findsNothing, + ); + }); + + testWidgets('says nothing when this build has no push at all', ( + tester, + ) async { + await tester.pumpWidget(wrap(PushOffNotice(onOpenPreferences: () {}))); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.shell_notice')), + findsNothing, + ); + }); + + testWidgets('the compact form carries the same accessible name', ( + tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + usePushDriver(_RecordingPushDriver()); + + await tester.pumpWidget( + wrap(PushOffNotice(compact: true, onOpenPreferences: () {})), + ); + await tester.pumpAndSettle(); + + expect( + find.text(trans('notifications.push_prompt.shell_notice')), + findsNothing, + ); + expect( + find.bySemanticsLabel( + trans('notifications.push_prompt.shell_notice_a11y'), + ), + findsOneWidget, + ); + + semantics.dispose(); + }); + + testWidgets('a tap calls onOpenPreferences exactly once', (tester) async { + int calls = 0; + usePushDriver(_RecordingPushDriver()); + + await tester.pumpWidget( + wrap(PushOffNotice(onOpenPreferences: () => calls++)), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.text(trans('notifications.push_prompt.shell_notice')), + ); + await tester.pumpAndSettle(); + + expect(calls, 1); + }); + }); + + // --------------------------------------------------------------------------- + // Every state + // --------------------------------------------------------------------------- + + testWidgets('every state lays out with no exception', (tester) async { + for (final (PushReachability, PushPromptAction) state in _everyState) { + await tester.pumpWidget( + wrap( + PushPrompt( + reachability: state.$1, + action: state.$2, + onEnable: () async {}, + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + } + }); +}