Notification Manager
On this page
Table of Contents#
- Singleton Lifecycle
- Channel Registration
- Push Driver Setup
- Send Dispatch Flow
- Polling Orchestration
- Realtime Delivery
- Stream Management
- Optimistic Updates with Rollback
Singleton Lifecycle#
NotificationManager is a process-level singleton implemented via a Dart factory constructor:
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:
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:
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:
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#
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:
- Call
notification.via(notifiable)— returnsList<String>of channel names - For each name: look up in
_channelsmap - Skip unknown names (warning only, no throw)
- Skip channels where
isAvailable == false - Await
channel.send(notifiable, notification)— channels run sequentially
Polling Orchestration#
The manager owns the NotificationPoller lifecycle but delegates all timer logic to the poller:
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:
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:
final bool live = await Notify.startRealtime(
channel: 'App.Models.User.$userId',
);
startRealtime() does five things in order, and each one answers a specific
failure:
- Refuses when there is no driver. It reads
broadcasting.defaultand returnsfalsefornull, 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. - 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 whymagic ^0.0.6is the floor;Echo.connectionis the accessor that makes the check possible. - Listens for
notification.createdexactly once. A secondlisten()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. - Stops the poller. The socket now covers it.
- Fetches the existing list once. A socket carries only what happens next.
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:
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#
connectionState is watched (and onReconnect deliberately is not, since a
reconnect necessarily transitions the state to connected and listening to both
would fetch the same list twice for one event):
| Transition | What happens |
|---|---|
anything but connected |
the poller is armed as a stand-in; the subscription stays, because realtime is still the intent |
connected |
the poller is dropped and the list is refetched once, because Reverb has no replay |
Without the first row, a socket that never comes back 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:
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:
fetchNotifications()— full refresh from APImarkAsRead(id)— optimistic updatemarkAllAsRead()— optimistic bulk updatedeleteNotification(id)— optimistic removal
Optimistic Updates with Rollback#
Read-mutate operations apply changes locally before the HTTP request completes, then revert on failure:
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) {
_safeLogError('Failed to mark notification as read: $e');
// 3. Rollback on failure
await fetchNotifications();
}
}
The same pattern applies to markAllAsRead() and deleteNotification(). 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:
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) {
_safeLogError('Failed to delete notification: $e');
_notifications.addAll(removed);
_notificationController.add(_notifications);
}
}
Related