flutter_notification_flow 0.1.0
flutter_notification_flow: ^0.1.0 copied to clipboard
A provider-independent Flutter package for normalizing and reliably routing notification interactions across different notification sources and application states.
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.