Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions doc/architecture/notification-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- <a name="toc-realtime"></a>[Realtime Delivery](#realtime)
- <a name="toc-streams"></a>[Stream Management](#streams)
- <a name="toc-optimistic"></a>[Optimistic Updates with Rollback](#optimistic)
- <a name="toc-identity-reconciled"></a>[Push Identity Reconcile Outcomes](#identity-reconciled)

---

Expand Down Expand Up @@ -390,6 +391,58 @@ Future<void> deleteNotification(String id) async {

---

## <a name="identity-reconciled"></a>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)
Expand Down
74 changes: 74 additions & 0 deletions doc/basics/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- <a name="toc-global-vs-type"></a>[Global Toggles vs Per-Type Preferences](#global-vs-type)
- <a name="toc-api"></a>[API Endpoints](#api)
- <a name="toc-ui"></a>[UI Integration Example](#ui)
- <a name="toc-push-prompt"></a>[Push Prompt Component](#push-prompt)

---

Expand Down Expand Up @@ -302,6 +303,79 @@ class User extends Model with Notifiable {

---

## <a name="push-prompt"></a>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)
Expand Down
2 changes: 2 additions & 0 deletions lib/magic_notifications.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down
35 changes: 35 additions & 0 deletions lib/src/models/push_identity_reconciled.dart
Original file line number Diff line number Diff line change
@@ -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,
});
}
40 changes: 40 additions & 0 deletions lib/src/notification_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -206,6 +207,12 @@ class NotificationManager {
final StreamController<PushDriver> _pushDriverAttachedController =
StreamController<PushDriver>.broadcast();

/// One outcome per reconcile pass that had a driver to act on. See
/// [onPushIdentityReconciled].
final StreamController<PushIdentityReconciled>
_pushIdentityReconciledController =
StreamController<PushIdentityReconciled>.broadcast();

/// The end of a session, for anything holding notification state of its own.
/// See [onSessionCleared].
final StreamController<void> _sessionClearedController =
Expand Down Expand Up @@ -865,6 +872,26 @@ class NotificationManager {
Stream<PushDriver> 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<PushIdentityReconciled> get onPushIdentityReconciled =>
_pushIdentityReconciledController.stream;

/// The external id this device should be subscribed as, `null` for nobody.
String? get pushIntent => _pushIntent;

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions lib/src/ui/components/push_prompt/index.dart
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading