Skip to content

Notification Manager

View raw .md
On this page

Table of Contents#


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.


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

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.


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

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();

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.


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

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;
  }
}

Push Identity Reconcile Outcomes#

reconcilePushIdentity() brings the device's subscribed identity in line with pushIntent (see Push Driver Setup 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

esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.