# Notification Manager

## Table of Contents

- <a name="toc-singleton"></a>[Singleton Lifecycle](#singleton)
- <a name="toc-channels"></a>[Channel Registration](#channels)
- <a name="toc-push-driver"></a>[Push Driver Setup](#push-driver)
- <a name="toc-send-flow"></a>[Send Dispatch Flow](#send-flow)
- <a name="toc-polling"></a>[Polling Orchestration](#polling)
- <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)

---

## <a name="singleton"></a>Singleton Lifecycle

`NotificationManager` is a process-level singleton implemented via a Dart factory constructor:

```dart
class NotificationManager {
  static final NotificationManager _instance = NotificationManager._internal();

  factory NotificationManager() {
    return _instance;
  }

  NotificationManager._internal();
}
```

Every call to `NotificationManager()` returns the same `_instance`. There is no `dispose` method — the instance lives for the application's lifetime. The `Notify` facade delegates to the same instance:

```dart
class Notify {
  static NotificationManager get manager => NotificationManager();
}
```

This means `NotificationManager()` and `Notify.manager` are identical references, and state set through one is visible through the other.

> [!NOTE]
> For testing, use `forgetDrivers()` to reset the singleton's internal state between tests. It clears every channel, every registered push driver factory, and every resolved push driver instance in one call; there is a single method for this now, not a pair split by concern.

---

## <a name="channels"></a>Channel Registration

Channels are stored in a `Map<String, NotificationChannel>` keyed by `channel.name`:

```dart
final Map<String, NotificationChannel> _channels = {};

void registerChannel(NotificationChannel channel) {
  _channels[channel.name] = channel;
}

bool hasChannel(String name) {
  return _channels.containsKey(name);
}
```

Registering a channel with an existing name replaces the previous instance — this is intentional for driver swapping in tests. The standard channels registered by `NotificationServiceProvider`:

| Name | Class | Registered By |
|------|-------|--------------|
| `'database'` | `DatabaseChannel` | You, in service provider |
| `'push'` | `PushChannel(driver)` | You, after driver setup |

---

## <a name="push-driver"></a>Push Driver Setup

The push driver resolves lazily through a name-keyed factory registry, not a
single nullable field the provider must fill before anything else runs:

```dart
void extend(String name, PushDriver Function() factory) {
  _pushFactories[name] = factory;
  _resolvedPushDriver = null;
  if (_pushDriver == null) _detachPushDriver();
}

PushDriver? get pushDriverOrNull {
  if (_pushDriver != null) return _pushDriver;
  if (_resolvedPushDriver != null) return _resolvedPushDriver;

  final factory = _pushFactory();
  if (factory == null) return null;

  final created = factory();
  _resolvedPushDriver = created;
  _attachPushDriver(created);
  return created;
}

PushDriver get pushDriver {
  final driver = pushDriverOrNull;
  if (driver == null) {
    throw NotificationException(
      'Push driver not configured. Register one with Notify.extend(name, '
      'factory), or set one explicitly with setPushDriver().',
      code: 'PUSH_DRIVER_NOT_CONFIGURED',
    );
  }
  return driver;
}
```

Resolution order for `pushDriver` / `pushDriverOrNull`: the instance
`setPushDriver()` was given (the escape hatch for a consumer holding an
already-built driver), then a previously-resolved registry instance, then a
fresh build from the registry. `_pushFactory()` resolves the factory named by
`notifications.push.driver` when more than one is registered, and falls back
to the single registered factory when there is exactly one and no config says
otherwise — with two or more and no matching name, there is no unambiguous
answer and the call site that only wants "is push configured at all" gets
`pushDriverOrNull == null` instead of a throw.

`NotificationServiceProvider.boot()` registers the built-in OneSignal factory
via `extend()`, then reads `pushDriver` (or `pushDriverOrNull` where absence
is a supported state) to call `driver.initialize(config)`. After
initialization, the `PushChannel` constructor receives the same driver
reference — `PushChannel.isAvailable` delegates to `driver.isSupported`.

---

## <a name="send-flow"></a>Send Dispatch Flow

```dart
Future<void> send(Notifiable notifiable, Notification notification) async {
  final channels = notification.via(notifiable);

  for (final channelName in channels) {
    final channel = _channels[channelName];

    if (channel == null) {
      print('Warning: Unknown notification channel: $channelName');
      continue;
    }

    if (!channel.isAvailable) {
      continue;
    }

    await channel.send(notifiable, notification);
  }
}
```

The dispatch flow:

1. Call `notification.via(notifiable)` — returns `List<String>` of channel names
2. For each name: look up in `_channels` map
3. Skip unknown names (warning only, no throw)
4. Skip channels where `isAvailable == false`
5. Await `channel.send(notifiable, notification)` — channels run sequentially

> [!NOTE]
> Channels execute sequentially, not in parallel. If `DatabaseChannel.send` throws, `PushChannel.send` will not run. Design channel implementations to be fault-tolerant.

---

## <a name="polling"></a>Polling Orchestration

The manager owns the `NotificationPoller` lifecycle but delegates all timer logic to the poller:

```dart
NotificationPoller? _poller;

void startPolling() {
  _poller ??= NotificationPoller(this);
  _poller!.start();
}

void stopPolling() {
  _poller?.stop();
  _poller = null;
}

void pausePolling() {
  _poller?.pause();
}

void resumePolling() {
  _poller?.resume();
}
```

`startPolling()` is idempotent — if the poller already exists and is running, `start()` returns immediately. The recommended lifecycle hooks:

| App Event | Call |
|-----------|------|
| User logged in | `Notify.startPolling()` |
| App backgrounded | `Notify.pausePolling()` |
| App foregrounded | `Notify.resumePolling()` |
| User logged out | `Notify.stopPolling()` |

`NotificationPoller` fetches immediately on `start()` and `resume()`, then fires every 30 seconds (default). The interval can be changed by constructing the poller directly:

```dart
final poller = NotificationPoller(manager, interval: Duration(seconds: 60));
poller.start();
```

---

## <a name="realtime"></a>Realtime Delivery

Polling is the fallback, not the only path. When the app has a broadcast driver,
the manager can take notification state off the socket instead:

```dart
final bool live = await Notify.startRealtime(
  channel: 'App.Models.User.$userId',
);
```

`startRealtime()` does five things in order, and each one answers a specific
failure:

1. **Refuses when there is no driver.** It reads `broadcasting.default` and
   returns `false` for `null`, empty, or the literal `'null'` driver. It does NOT
   try to subscribe and see what happens: the null driver accepts a subscription
   and silently delivers nothing, so an attempt-based probe would report success
   on the one configuration that cannot work, silence the poller, and leave the
   bell permanently empty.
2. **Connects only if nothing is connected.** The check saves a round trip;
   magic's Reverb driver joins an attempt in flight or reuses a live connection
   on its own. `Echo.connection` is the accessor that makes the check possible.
   A connect that fails (the realtime server is down) does not stop it: the next
   steps run anyway, the driver records the channel and subscribes it on its
   next handshake, and the poller keeps running until the server confirms the
   channel. Giving up here left realtime off for the whole session when the
   server was down at launch. It still returns `true`, since realtime is armed;
   only a failure to subscribe returns `false`.
3. **Listens for `notification.created` exactly once.** A second `listen()` for
   one event name REPLACES the earlier handler rather than adding to it, so
   registering the same event anywhere else would silently drop this one.
4. **Keeps the poller until the channel is subscribed.** `connected` is emitted
   before a private channel is authorised, so a connected but unsubscribed socket
   delivers nothing. The poller stops only when the channel's own
   `onSubscriptionChanged` reports `true`.
5. **Fetches the existing list once** when that confirmation arrives (at once if
   the channel is already subscribed). A socket carries only what happens next,
   and Reverb has no replay.

### The frame is the state

A `notification.created` frame carries the whole row in the same shape
`GET /notifications` returns, so it is decoded and applied rather than treated as
a signal to fetch:

```dart
void _applyRealtimeFrame(BroadcastEvent event) {
  final incoming = DatabaseNotification.fromMap(event.data);
  _notifications = [
    incoming,
    ..._notifications.where((n) => n.id != incoming.id),
  ];
  _notificationController.add(_notifications);
}
```

Newest first, keyed by id, so a redelivery replaces the held row instead of
appending a duplicate the bell would count twice. A payload the decoder cannot
read is logged and dropped: it must not throw into the driver's listener, and it
must not clear what is already held, because a backend one version ahead is a
reason to miss one row and not to empty the list.

### Both directions of degradation

The poller follows the channel's subscription, with `connectionState` watched
only to arm it on a drop:

| Signal | What happens |
|--------|--------------|
| `connectionState` anything but `connected` | the poller is armed as a stand-in; the subscription stays, because realtime is still the intent |
| `connectionState` `connected` | nothing: the channel is not authorised yet |
| `onSubscriptionChanged` `true` | the poller is dropped and the list is refetched once, because Reverb has no replay |
| `onSubscriptionChanged` `false` (drop, leave or failed auth) | the poller is armed again |

Without the poller rows, a socket that never comes back (or a channel whose auth
fails) is a bell that never updates again.

### Lifecycle hooks

| App Event | Call |
|-----------|------|
| User logged in | `Notify.startRealtime(channel: ...)` then `Notify.startPolling()` |
| User switched account | `Notify.startRealtime(channel: ...)` with the new channel |
| User logged out | `Notify.stopRealtime()` then `Notify.stopPolling()` |

Both calls on the login row are safe in either order and are idempotent per
channel, so wiring them to an auth-state listener that fires on every login,
logout and restore is the intended shape. `startPolling()` is a no-op while
realtime is live, so a consumer never has to branch on whether a socket is up.

---

## <a name="streams"></a>Stream Management

Database notifications are distributed via a broadcast `StreamController`:

```dart
final StreamController<List<DatabaseNotification>> _notificationController =
    StreamController<List<DatabaseNotification>>.broadcast();

List<DatabaseNotification> _notifications = [];

Stream<List<DatabaseNotification>> notifications() async* {
  yield _notifications;          // emit cached state immediately
  yield* _notificationController.stream;  // then stream all future updates
}
```

The `notifications()` method is an async generator that yields the current cached list to new listeners immediately, then forwards all future events from the broadcast controller. This means a widget that subscribes mid-session receives the current notification list without waiting for the next poll interval.

Events are emitted to the stream in four situations:

1. `fetchNotifications()` — full refresh from API
2. `markAsRead(id)` — optimistic update
3. `markAllAsRead()` — optimistic bulk update
4. `deleteNotification(id)` — optimistic removal

---

## <a name="optimistic"></a>Optimistic Updates with Rollback

Read-mutate operations apply changes locally before the HTTP request completes, then revert on failure. Whether the caller is TOLD about that failure differs per operation, and the note below the `deleteNotification` snippet says which is which:

```dart
Future<void> markAsRead(String id) async {
  // 1. Optimistic update
  final index = _notifications.indexWhere((n) => n.id == id);
  if (index != -1) {
    _notifications[index] = _notifications[index].copyWith(
      readAt: DateTime.now(),
    );
    _notificationController.add(_notifications);
  }

  // 2. Sync with backend
  try {
    await Http.post('/notifications/$id/read');
  } catch (e) {
    NotificationLog.error('Failed to mark notification as read: $e');
    // 3. Rollback on failure
    await fetchNotifications();
  }
}
```

The same pattern applies to `markAllAsRead()`. Rollback always calls `fetchNotifications()` to restore authoritative server state rather than trying to undo the local mutation, which avoids edge cases from concurrent updates.

`deleteNotification` preserves the removed items for rollback, and then **rethrows**:

```dart
Future<void> deleteNotification(String id) async {
  final removed = _notifications.where((n) => n.id == id).toList();
  _notifications.removeWhere((n) => n.id == id);
  _notificationController.add(_notifications);

  try {
    await Http.delete('/notifications/$id');
  } catch (e) {
    NotificationLog.error('Failed to delete notification: $e');
    _notifications.addAll(removed);
    _notificationController.add(_notifications);

    rethrow;
  }
}
```

> [!IMPORTANT]
> `deleteNotification` is the one mutation here that does NOT swallow, and that is deliberate. It used to complete normally on failure, which left a caller no way to tell a delete that worked from one that did not: the only thing a person saw was the row leaving the list and coming back, with nothing said. The rollback is unchanged; the future now carries the failure.
>
> `markAsRead` and `markAllAsRead` still swallow. Their failure is recoverable by looking again and costs a person nothing, while a delete that silently did not happen is the case where the screen and the server disagree about something destructive.
>
> A caller that wants the old silence adds a `catch`. `NotificationsListView` catches it and reports `notifications.delete_failed` through `Magic.error`, which is a key the HOST must supply: this package ships no translation catalogue, and a missing key renders as itself.

> [!IMPORTANT]
> **`NotificationsListView.onDelete` is `Future<bool> Function(String id)?`, and the `bool` is load-bearing.** The screen is a separately paginated fetch, so a delete the server accepted has to be followed by a reload: a row leaving page one pulls one up from page two, and only the server knows which. So the answer decides whether that reload happens.
>
> | Outcome | Meaning | The list |
> |---|---|---|
> | `true` | the row is gone | reloads the current page |
> | `false` | the host chose not to go ahead | is left alone |
> | throws | the request failed and the manager rolled the row back | reloads, because what the server holds is now unknown |
>
> `false` is what a confirmation dialog needs. Before the callback could answer, the row reloaded after EVERY tap, so a host that asks before deleting spent a full `GET /notifications` every time somebody declined. A callback that always deletes returns `true`; a callback that can refuse returns `false` on that path.
>
> Two more keys the HOST must supply, for the same reason as `delete_failed`: **`notifications.delete`** names the row's delete control for a screen reader (it is a bare glyph with no text of its own), and **`notifications.channel_sms`** labels the SMS row on the preferences screen. Without them each renders as its own key.
>
> And two more since the bulk control landed: **`notifications.bulk_title`** and **`notifications.bulk_description`** head the card above the preference matrix. These are new copy on a screen an adopter has ALREADY translated, so an upgrade that does not add them ships the raw keys as visible text.

> [!TIP]
> The `Notify` facade exposes all these operations as static methods (`Notify.markAsRead`, `Notify.deleteNotification`, etc.), which simply delegate to the same manager instance.

---

## <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)
- [Channels](https://magic.fluttersdk.com/packages/notifications/basics/channels)
- [Drivers](https://magic.fluttersdk.com/packages/notifications/basics/drivers)
