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