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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
## [Unreleased]

### Added
- **`PushStateReporter`, reached as `Notify.pushState`: the device tells its backend whether a push can reach it, and withdraws that on sign-out.** Moved here from the one app that had written it, so every adopter gets the same answer to "who keeps receiving pages after sign-out". `watch()` reports `PushDeliverySnapshot.toMap()` verbatim after every `onPushIdentityReconciled` pass towards the signed-in person, whenever the attached driver's permission or subscription stream moves (attached late through `onPushDriverAttached`), and once as `unavailable` off `Auth.stateNotifier` for a build with no driver at all, since no pass is emitted there. A memo of the last ACCEPTED state stops repeats, a refused post is retried on the next event, and the memo is forgotten on every `AuthLogout`. `release()` posts `{subscription_id}` alone for this device (the live read, falling back to the last accepted report's id), must run before `Auth.logout()` drops the token, joins a release already in flight and does not post a subscription it already released, so calling it twice is safe. `isConfigured` reads true only when both endpoints are named, and lets a starter kit's sign-out path skip the release; a report path without a release path keeps the whole reporter off and logs why, since a device no sign-out can withdraw would keep vouching for whoever left it last. `forget()` clears the memo.

**Off by default, with no default path.** `notifications.push_state.report_path` and `release_path` name the endpoints and are null in the install stub, so an app whose backend has no such route sends nothing. `notifications.push_state.external_id_prefix` (default `user_`) is how the reporter recognises a pass for the signed-in person, `<prefix><Auth.id()>`; it has to match what the app passes to `Notify.initializePush`. `NotificationManager.forgetDrivers()` resets the reporter too, because its watch lives on the manager's streams. The backend contract is documented in `doc/basics/laravel-backend-setup.md`. (`lib/src/support/push_state_reporter.dart`, `lib/src/notification_manager.dart`, `lib/src/facades/notify.dart`, `lib/magic_notifications.dart`, `assets/stubs/install/notification_config.stub`, `test/support/push_state_reporter_test.dart`, `doc/basics/shipping-push.md`, `doc/basics/laravel-backend-setup.md`, `README.md`)

- **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.
Expand All @@ -11,6 +15,9 @@

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`)

### Changed
- **`magic` floor moves `^0.0.16` to `^0.0.22`.** `PushStateReporter` calls `Event.listenAny`, which magic 0.0.22 introduces, so an adopter on an older magic now gets a version-solve error instead of a compile error inside this package. (`pubspec.yaml`)

## [0.3.4] - 2026-09-22

### Changed
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,37 @@ four presentations and the translation keys a host has to add.
identity reconcile pass (login, logout, or a driver attaching later): see
[Push Identity Reconcile Outcomes](doc/architecture/notification-manager.md#identity-reconciled).

### Tell Your Backend Whether the Device Can Be Paged

OneSignal accepts a push for a subscription that is denied, opted out or gone,
and reports nothing back. `Notify.pushState` posts the device's own
reachability to your backend whenever it changes, and withdraws it on
sign-out. It stays off until both endpoints are configured (there is no
default path):

```dart
// config: 'push_state': {
// 'report_path': '/devices/push-state',
// 'release_path': '/devices/push-state/release',
// },

// In a provider's boot(), once auth is registered.
Notify.pushState.watch();
```

The release has to run before `Auth.logout()` drops the token (see below). The
server half is in
[Laravel Backend Setup](doc/basics/laravel-backend-setup.md#onesignal-push);
[Shipping Push](doc/basics/shipping-push.md#telling-your-backend-whether-the-device-can-be-paged)
covers when a report is sent.

### Clean Up on Logout

```dart
Future<void> onLogout() async {
Notify.stopRealtime();
Notify.stopPolling();
if (Notify.pushState.isConfigured) await Notify.pushState.release();
await Notify.logoutPush();
}
```
Expand Down
12 changes: 12 additions & 0 deletions assets/stubs/install/notification_config.stub
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ Map<String, dynamic> get notificationConfig => {
// API, from the system that already owns the fact.
'share_user_attributes': false,
},
// Optional. Where `Notify.pushState` tells your backend whether a push can
// reach this device, and where a sign-out releases it. Both are relative
// to the HTTP base url, and both are null (off) until your backend has
// the endpoints: there is no default path, so an app without them sends
// nothing. See doc/basics/laravel-backend-setup.md for the contract.
'push_state': {
'report_path': null, // e.g. '/devices/push-state'
'release_path': null, // e.g. '/devices/push-state/release'
// Optional. The prefix your declared external id carries, 'user_' when
// absent. Must match what you pass to Notify.initializePush.
'external_id_prefix': null,
},
'database': {
'enabled': true,
'polling_interval': 30, // seconds
Expand Down
2 changes: 1 addition & 1 deletion doc/architecture/notification-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ failure:
2. **Connects only if nothing is connected.** `Echo.connect()` is not idempotent
in magic's Reverb driver: it assigns a fresh channel without closing the
previous one, so a redundant call opens a second WebSocket and leaks the first.
This is why `magic ^0.0.6` is the floor; `Echo.connection` is the accessor that
This is why the `magic` floor cannot sit below 0.0.6; `Echo.connection` is the accessor that
makes the check possible.
3. **Listens for `notification.created` exactly once.** A second `listen()` for
one event name REPLACES the earlier handler rather than adding to it, so
Expand Down
39 changes: 39 additions & 0 deletions doc/basics/laravel-backend-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,45 @@ public function routeNotificationForOneSignal(): array
> [!TIP]
> The `user_` prefix is required to avoid OneSignal's blocked external_id values. Both Flutter and Laravel must use the same format: `user_{id}`.

### Device Reachability (Optional)

OneSignal accepts a push for a subscription that cannot be woken, so only the
device knows whether a page will arrive. `Notify.pushState` reports that to
your backend and withdraws it on sign-out. It stays off until the client names
both endpoints (there is no default path):

```dart
'push_state': {
'report_path': '/devices/push-state',
'release_path': '/devices/push-state/release',
},
```

Both routes sit behind `auth:sanctum`. The user is always the session's; no
body field names a person beyond the device's own alias.

**`POST {report_path}`** carries `PushDeliverySnapshot.toMap()` unchanged:

| Key | Rule |
|---|---|
| `external_id` | present, nullable string; must equal the caller's own alias (`user_{id}`) when set |
| `subscription_id` | present, nullable string |
| `reachability` | one of `on`, `off`, `blocked`, `unavailable` (the `PushReachability` names) |
| `captured_at` | ISO-8601 UTC, the device's own clock |

Store it per `(user, subscription_id)` with `updateOrCreate`, stamp your own
`reported_at`, and answer `204`. A null key is a fact ("this device holds no
subscription id"), so validate with `present`, not `sometimes`.

**`POST {release_path}`** carries `{"subscription_id": "..."}` only. Delete that
one row for the session's user and answer `204`. The client calls it before
`Auth.logout()` drops the token, and only for the device being signed out of:
the person's other devices keep paging them.

The client reports on change (sign-in, permission, subscription swap), never on
a timer, and re-posts only after a refusal. A refused release is logged and the
sign-out proceeds, so size your freshness horizon for a stale row.

---

## <a name="broadcast"></a>Socket Delivery (Broadcast)
Expand Down
29 changes: 29 additions & 0 deletions doc/basics/shipping-push.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,39 @@ updating, check the site type before the config.

---

## Telling your backend whether the device can be paged

Everything above decides whether a push CAN arrive. Whether your backend knows
it is a separate question: OneSignal accepts a push for a subscription that is
denied, opted out or gone, and reports nothing back. If your backend escalates
on push alone, `Notify.pushState` gives it the device's own answer. Configure
both endpoints (see [Laravel Backend Setup](laravel-backend-setup.md#onesignal-push)
for the server half), arm the watch once, and release before the token goes:

```dart
// In a provider's boot(), once auth is registered.
Notify.pushState.watch();

// On sign-out, BEFORE Auth.logout(): a release after it is a guaranteed 401.
if (Notify.pushState.isConfigured) await Notify.pushState.release();
await Auth.logout();
```

`watch()` reports after every identity reconcile for the signed-in person
(`<external_id_prefix><Auth.id()>`, `user_` by default), whenever the driver's
permission or subscription moves, and once as `unavailable` for a build with no
push driver at all. A memo stops repeats and is forgotten on every
`AuthLogout`. Skip the release and the server keeps vouching for a handset the
person has left, under their name, until its own freshness horizon expires.

---

## Before you submit

- `dart run <app>:artisan notifications:doctor` is clean, including the iOS
configuration rows.
- If your backend reads device reachability, a sign-out from the release build
posts to `notifications.push_state.release_path` before the token is gone.
- The `.env` inside the built artifact is the production one. Unzip the `.ipa`
and read `Payload/*.app/Frameworks/App.framework/flutter_assets/.env`.
- The signed binary carries the production entitlement:
Expand Down
1 change: 1 addition & 0 deletions lib/magic_notifications.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export 'src/models/push_user_attributes.dart';
// Core
export 'src/notification_manager.dart';
export 'src/notification_poller.dart';
export 'src/support/push_state_reporter.dart';

// Facade
export 'src/facades/notify.dart';
Expand Down
16 changes: 16 additions & 0 deletions lib/src/facades/notify.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import '../models/database_notification.dart';
import '../models/paginated_notifications.dart';
import '../models/push_user_attributes.dart';
import '../notification_manager.dart';
import '../support/push_state_reporter.dart';
import '../ui/notification_view_registry.dart';
import '../ui/views/notification_preferences_view.dart';
import '../ui/views/notifications_list_view.dart';
Expand Down Expand Up @@ -239,6 +240,21 @@ class Notify {
await manager.logoutPush();
}

/// Reports whether a push can reach this device to the app's backend, and
/// releases the device on sign-out.
///
/// Off until `notifications.push_state.report_path` and `release_path` are
/// configured. Arm it once from a provider's `boot()`, and release before
/// the token is dropped:
///
/// ```dart
/// Notify.pushState.watch();
///
/// if (Notify.pushState.isConfigured) await Notify.pushState.release();
/// await Auth.logout();
/// ```
static PushStateReporter get pushState => manager.pushState;

/// Registers how this app describes whoever signs in, once, for every later
/// login and account switch.
///
Expand Down
11 changes: 11 additions & 0 deletions lib/src/notification_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'models/push_subscription.dart';
import 'models/push_user_attributes.dart';
import 'notification_poller.dart';
import 'support/notification_log.dart';
import 'support/push_state_reporter.dart';

/// Core notification manager.
///
Expand Down Expand Up @@ -218,6 +219,11 @@ class NotificationManager {
final StreamController<void> _sessionClearedController =
StreamController<void>.broadcast();

/// Reports this device's push reachability to the host's backend, and
/// releases it on sign-out. Off until the app configures its endpoints; see
/// [PushStateReporter].
late final PushStateReporter pushState = PushStateReporter(this);

/// Notification poller for periodic fetching
NotificationPoller? _poller;

Expand Down Expand Up @@ -366,7 +372,12 @@ class NotificationManager {
/// test would otherwise describe the person in the next. What it wrote is
/// forgotten rather than taken back, because this seam does not touch a
/// device; a driver it just dropped is not one to issue removals through.
///
/// [pushState] is reset with them: its watch lives on this manager's
/// streams, and a subscription surviving here would report inside the next
/// test.
void forgetDrivers() {
pushState.reset();
_channels.clear();
_pushFactories.clear();
_pushDriver = null;
Expand Down
Loading
Loading