flutter_notification_flow
A provider-independent Flutter package for normalizing and reliably routing notification interactions across different notification sources and application states.
π‘ Overview
Flutter applications frequently receive notification interactions from diverse sources:
- Firebase Cloud Messaging (FCM)
- Flutter Local Notifications
- OneSignal / Pusher / WebSockets / Custom Backends
These notification taps occur across distinct application lifecycles:
- Foreground events
- Background events (user taps notification tray)
- Terminated state / Cold start launches
Without a unified routing layer, developers end up duplicating navigation logic across onMessageOpenedApp, getInitialMessage, local notification callbacks, and deep links. This causes race conditions during startup, duplicate route pushes, and difficult-to-maintain spaghetti code.
flutter_notification_flow provides a clean, provider-independent layer that normalizes raw notification data into an immutable NotificationPayload and routes it to registered handlers or event callbacksβcomplete with built-in cold-start queueing, sliding-window deduplication, and observable lifecycle streams.
β¨ Features
- π― Provider-Independent: Zero required dependencies on Firebase, OneSignal, or any push service. Connect any provider in just a few lines.
- π¦ Normalized Payloads: Converts flat maps, nested maps, and JSON strings into an immutable
NotificationPayload. - π§ Dual Routing Models:
- Route-Based Handlers: Execute context-aware handlers using Flutter's
NavigatorState. - Event-Based Callbacks: Listen via
onNotificationfor frameworks like GetX, GoRouter, Riverpod, and Bloc.
- Route-Based Handlers: Execute context-aware handlers using Flutter's
- β³ Cold Start & Lifecycle Readiness: Buffers notification events in a FIFO memory queue if the app is still launching or the
Navigatoris not ready, then automatically processes them when mounted. - π‘οΈ Duplicate Protection: Configurable sliding time-window deduplication using notification IDs or deterministic payload fingerprints.
- β οΈ Graceful Fallbacks & Error Isolation: Handles unknown notification types and runtime handler exceptions without crashing the host app.
- π‘ Observable Event Stream: Broadcast stream of lifecycle events (
received,queued,handling,handled,duplicate,unhandled,failed). - πͺΆ Lightweight & Fast: Pure Dart and Flutter SDK capabilities only with zero unnecessary dependencies.
ποΈ How It Works
Notification Sources (FCM, Local Notifications, OneSignal, etc.)
β
βΌ
Normalize Payload Data
(NotificationPayload.fromMap / fromJson)
β
βΌ
NotificationFlow
β
ββββββββββββββββββββ΄βββββββββββββββββββ
βΌ βΌ
Context Available? Context Unavailable? (Cold start)
β β
Route Resolver & Deduplication Enqueue into NotificationQueue (FIFO)
β β
βββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββ
βΌ βΌ βΌ
Route Handler Event Callback Unhandled Callback
(Navigator push / dialog) (GetX / GoRouter / Riverpod) (Logging / Analytics)
β β β
βββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββ
β
βΌ
Application Action / Navigation
π¦ Installation
Add flutter_notification_flow to your pubspec.yaml:
dependencies:
flutter_notification_flow: ^0.1.0
Then install dependencies:
flutter pub get
π Quick Start
import 'package:flutter/material.dart';
import 'package:flutter_notification_flow/flutter_notification_flow.dart';
final navigatorKey = GlobalKey<NavigatorState>();
final notificationFlow = NotificationFlow(
navigatorKey: navigatorKey,
routes: {
'chat': (context, payload) {
final chatId = payload.data['chatId'];
Navigator.of(context).pushNamed('/chat', arguments: chatId);
},
'post': (context, payload) {
final postId = payload.data['postId'];
Navigator.of(context).pushNamed('/post', arguments: postId);
},
},
onUnhandledNotification: (payload) {
debugPrint('Unhandled notification type: ${payload.type}');
},
);
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await notificationFlow.initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
home: const Scaffold(
body: Center(child: Text('Home Screen')),
),
);
}
}
π Basic Usage
Option A: Route-Based Handling (Recommended)
When using standard Flutter navigation, provide a GlobalKey<NavigatorState> and register route handlers:
final notificationFlow = NotificationFlow(
navigatorKey: navigatorKey,
routes: {
'chat': (context, payload) async {
final chatId = payload.data['chatId'];
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatPage(chatId: chatId)),
);
},
'profile': (context, payload) {
final userId = payload.data['userId'];
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ProfilePage(userId: userId)),
);
},
},
);
Option B: Event-Based Handling (Custom Navigation / State Management)
If your app uses GetX, GoRouter, Riverpod, or Bloc, you can handle notification interactions without requiring a navigatorKey:
final notificationFlow = NotificationFlow(
onNotification: (payload) {
switch (payload.type) {
case 'chat':
// Example with GoRouter:
// context.go('/chat/${payload.data['chatId']}');
break;
case 'profile':
// Example with GetX:
// Get.toNamed('/profile', arguments: payload.data);
break;
}
},
);
ποΈ Notification Payload Structure
NotificationPayload automatically normalizes both nested and flat structures:
Nested Payload:
{
"type": "chat",
"id": "notif_101",
"data": {
"chatId": "c_99",
"sender": "Alice"
}
}
Flat Payload:
{
"type": "chat",
"id": "notif_101",
"chatId": "c_99",
"sender": "Alice"
}
Both produce identical normalized NotificationPayload instances:
payload.typeβ'chat'payload.idβ'notif_101'payload.dataβ{'chatId': 'c_99', 'sender': 'Alice'}
Common alternative keys such as notification_type, notificationType, notification_id, message_id, and messageId are also parsed seamlessly.
π Provider Integrations
Firebase Cloud Messaging (FCM)
The package remains completely provider-independent. Integrate Firebase Messaging in your application code:
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_notification_flow/flutter_notification_flow.dart';
void setupFirebaseMessaging(NotificationFlow flow) async {
// 1. App opened from terminated state (cold start)
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
await flow.handleMap(initialMessage.data);
}
// 2. App opened from background
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
flow.handleMap(message.data);
});
}
Flutter Local Notifications
Forward tapped local notification payload strings directly:
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_notification_flow/flutter_notification_flow.dart';
void onSelectNotification(NotificationResponse response, NotificationFlow flow) {
final payloadString = response.payload;
if (payloadString != null && payloadString.isNotEmpty) {
flow.handleJson(payloadString);
}
}
π‘οΈ Duplicate Notification Protection
Notifications can occasionally be processed multiple times during rapid app launches (for instance, when getInitialMessage and onMessageOpenedApp both fire for the same interaction).
NotificationDuplicateGuard caches recently seen notifications in memory:
final notificationFlow = NotificationFlow(
config: const NotificationFlowConfig(
enableDuplicateProtection: true,
duplicateCacheDuration: Duration(minutes: 5), // Cache sliding window
enableDebugLogs: true,
),
);
Deduplication Strategy:
- Explicit Notification ID: If
payload.idis present, it is used as the unique key. - Deterministic Data Fingerprint: If
payload.idis omitted, a deterministic fingerprint is generated based onpayload.typeand recursively sorted key-value pairs ofpayload.data.
β³ Cold Start & Pending Queue
When a notification arrives before the Flutter widget tree or NavigatorState is mounted (navigatorKey.currentContext == null):
- The notification is placed into an in-memory FIFO
NotificationQueue. - A
NotificationFlowStatus.queuedevent is emitted on the event stream. - When
notificationFlow.initialize()orprocessPendingNotifications()is called, pending notifications are drained and executed in strict arrival order.
π‘ Event Stream & Observability
Subscribe to events to monitor lifecycle status transitions across your entire app:
final subscription = notificationFlow.events.listen((event) {
print('Status: ${event.status}, Type: ${event.payload.type}');
});
Lifecycle Statuses (NotificationFlowStatus)
| Status | Description |
|---|---|
received |
The notification was received by NotificationFlow. |
queued |
Navigator context is not yet ready; payload was queued. |
handling |
Handler execution has started. |
handled |
Handler or callback completed successfully. |
unhandled |
No handler was registered for this notification type. |
duplicate |
The notification was identified as a duplicate and ignored. |
failed |
An error occurred during routing or handler execution. |
β οΈ Error Handling
Errors during route execution or payload parsing are isolated and reported through onError without crashing the application:
final notificationFlow = NotificationFlow(
onError: (payload, error, stackTrace) {
debugPrint('NotificationFlow error handling "${payload?.type}": $error');
},
);
π API Reference
NotificationFlow
NotificationFlow({navigatorKey, routes, onNotification, onUnhandledNotification, onError, config})β Main coordinator.Future<void> initialize()β Initializes flow and flushes queued cold-start notifications.Future<bool> handle(NotificationPayload payload, {BuildContext? context})β Handles normalized payload.Future<bool> handleMap(Map<dynamic, dynamic> map, {BuildContext? context})β Parses map and handles payload.Future<bool> handleJson(String jsonString, {BuildContext? context})β Parses JSON string and handles payload.Future<void> processPendingNotifications({BuildContext? context})β Manually processes queued notifications.void registerRoute(String type, NotificationRouteHandler handler)β Dynamically registers a route handler.void registerRoutes(Map<String, NotificationRouteHandler> routes)β Registers multiple route handlers.bool unregisterRoute(String type)β Removes a registered route handler.bool hasRoute(String type)β Checks if a route handler exists.Stream<NotificationFlowEvent> get eventsβ Broadcast stream of lifecycle events.int get pendingCountβ Number of pending notifications currently queued.List<NotificationPayload> get pendingNotificationsβ Snapshot of queued notifications.Future<void> dispose()β Releases stream controllers and internal caches.
NotificationPayload
NotificationPayload({required type, required data, id})β Immutable payload.NotificationPayload.fromMap(Map<dynamic, dynamic> map)β Factory from raw map.NotificationPayload.fromJson(String jsonString)β Factory from JSON string.String get fingerprintβ Deterministic unique string for deduplication.Map<String, dynamic> toMap()/String toJson()β Serialization helpers.
NotificationFlowConfig
enableDuplicateProtection: bool(default:true)duplicateCacheDuration: Duration(default:Duration(minutes: 5))enableDebugLogs: bool(default:false)autoProcessQueueOnReady: bool(default:true)
π± Example Application
A complete example application is available in the example/ directory.
To run the example app locally:
cd example
flutter pub get
flutter run
π€ Contributing
Contributions are welcome! Please check out CONTRIBUTING.md for guidelines on code formatting, static analysis, running tests, and opening pull requests.
π License
Distributed under the MIT License. See LICENSE for details.
Libraries
- flutter_notification_flow
- A lightweight, provider-independent notification interaction router, queue, and handler for Flutter applications.