In this article, I’ll be explaining broadly how I handle Notifications in Flutter using BLoc. There’s so much to cover that I’m going to dive right in!
Let’s start!
Before beginning, you must know how to setup firebase_messaging and local_notification in Flutter. There are plenty of tutorials that you can find online, and I will leave that task to your discretion.
When you configure the firebase messaging plugin, messages are sent to your Flutter app via onMessage
, onLaunch
, and onResume
callbacks.
The following things are to be handled in NotificationBloc:
All of these cases must be handled within the main() of lib/main.dart file or in the MyApp widget itself because we need app-wide notifications.
void main() => runApp(MyApp());
But where should one initialize the required notification configurations, you ask? e.g., ask notification permission on iOS, FCM token generation.
We can do that before flutter starts rendering the app or MyApp widget renders the screen for the first time and refreshes the state of the widget if required.
The later will attach the widget to the tree asynchronously which can consist of network calls, image downloading, etc. Sounds a little unnecessary doesn’t it?
Here is a better way to do this:
If you want to do some awaiting tasks in main() then WidgetFlutterBinding.ensureInitialized() method must be executed before runApp(), like this:
void main() async { WidgetsFlutterBinding.ensureInitialized(); ... ... ... runApp(MyApp()); }
In case you open the source code then you will find one of the comments saying,
You only need to call this method if you need the binding to be initialized before calling [runApp].
But what is WidgetFlutterBinding?
WidgetFlutterBinding is the glue between the widgets layer and the Flutter engine.
Enough of the theory, show me the code!
To start with Notification BLoc we need to define Events and States for the Bloc. Here is NotificationEvent.dart
class NotificationEvent { | |
//carries the payload sent for notification | |
final String payload; | |
const NotificationEvent(this.payload); | |
} | |
class NotificationErrorEvent extends NotificationEvent { | |
final String error; | |
const NotificationErrorEvent(this.error) : super(null); | |
} |
NotificationEvent will carry notification payload and NotificationErrorEvent will carry error occurred during initialization or somewhere else.
We can represent the Notification state as follow.
class NotificationState extends Equatable { | |
const NotificationState(); | |
@override | |
List<Object> get props => []; | |
} | |
class StartUpNotificationState extends NotificationState {} | |
class IndexedNotification extends NotificationState { | |
final int index; | |
IndexedNotification(this.index); | |
@override | |
List<Object> get props => [this.index]; | |
@override | |
bool operator ==(Object other) => false; | |
@override | |
int get hashCode => super.hashCode; | |
} |
Here I’ve created base NotificationState class, StartUpNotificationState to represent the initial state of the BLoc and IndexedNotificationState to change the index of the Bottom Navigation page.
You should create your state class to handle every type of notifications for your app if required.
Now we should define NotificationBloc.
We will initialize the instances for local notification and firebase messaging instance in constructor.
NotificationBloc() { _localNotifications = new FlutterLocalNotificationsPlugin(); _firebaseMessaging = new FirebaseMessaging(); }
We will require one initialize() method to initialize all the configurations in main() method.
initialize() async { NotificationAppLaunchDetails _appLaunchDetails = await _localNotifications.getNotificationAppLaunchDetails(); var initializationSettings = _getPlatformSettings(); await _localNotifications.initialize(initializationSettings, onSelectNotification: _handleNotificationTap); _createNotificationChannel(); if (Platform.isIOS) { var hasPermission = await _requestIOSPermissions(); if (hasPermission) { await _fcmInitialization(); } else { add(NotificationErrorEvent( "You can provide permission by going into Settings later.")); } } else { await _fcmInitialization(); } _hasLaunched = _appLaunchDetails.didNotificationLaunchApp; if (_hasLaunched) { if (_appLaunchDetails.payload != null) { _payLoad = _appLaunchDetails.payload; } } }
Following things are happening in the initialize() method,
Before we move forward with FCM initialization, we need to define one Notification class which can be used to represent one notification.
part ‘notification.g.dart’; | |
abstract class Notification | |
implements Built<Notification, NotificationBuilder> { | |
Notification._(); | |
factory Notification([updates(NotificationBuilder b)]) = _$Notification; | |
@nullable | |
String get notificationType; | |
@nullable | |
int get notificationId; | |
@nullable | |
String get notificationTitle; | |
@nullable | |
String get notificationBody; | |
String toJson() { | |
return json.encode(serializers.serializeWith(Notification.serializer, this)); | |
} | |
static Notification fromJson(String jsonString) { | |
return serializers.deserializeWith( | |
Notification.serializer, json.decode(jsonString)); | |
} | |
static Serializer<Notification> get serializer => _$notificationSerializer; | |
} |
Here I’m keeping notificationType member variable to identify the type of notification I need to handle. It will be used in mapEventToState() method to yield different states according to types. e.g Yield IndexedNotification to change the index of Bottom navigation.
Here is _fcmInitialization() method,
Future _fcmInitialization() async { try { _fcmToken = await _firebaseMessaging.getToken(); _firebaseMessaging.onTokenRefresh.listen((event) { _fcmToken = event; }); _firebaseMessaging.configure( onMessage: (Map<String, dynamic> message) async { Notification notification = convertToNotification(_notificationId++, message); await _showNotification(notification); }, onLaunch: (Map<String, dynamic> message) async { print("onLaunch: $message"); Notification notification = convertToNotification(_notificationId++, message); _hasLaunched = true; _payLoad = notification.toJson(); }, onResume: (Map<String, dynamic> message) async { print("onResume: $message"); Notification notification = convertToNotification(_notificationId++, message); add(NotificationEvent(notification.toJson())); }, ); } catch (e) { add(NotificationErrorEvent(e.toString())); } }
It does the following things.
onMessage
will convert received payload into the Notification object we defined earlier and create a new local notification. If the user taps on that notification it will call _handleNotificationTap().onLaunch
will assign _hasLaunched and _payload will be used when our MyApp widget is attached to the tree. I’ll come to that part later in this article soon.onResume
will add one NotificationEvent for Bloc to handle.Future _handleNotificationTap(String payload) async { if (payload != null) { add(NotificationEvent(payload)); } }
This method is adding new events for Bloc to handle when the notification is tapped.
Now we will see how we can implement mapEventToState() method.
@override Stream<NotificationState> mapEventToState(NotificationEvent event) async* { switch (event.runtimeType) { case NotificationEvent: Notification notification = Notification.fromJson(event.payload); if (notification.notificationType == Constants.notificationTypeIndex) { yield IndexedNotification(1); } break; case NotificationErrorEvent: yield NotificationErrorState((event as NotificationErrorEvent).error); break; } }
I’ve handled one notification type here which will change the index of bottom navigation to 1. You can yield multiple states to handle different types of notifications.
Check out the complete code of NotificationBloc here.
Now, we will create an instance of NotificationBloc and call the initialize() method in the main() method. Like this,
void main() async { WidgetsFlutterBinding.ensureInitialized(); NotificationBloc notificationBloc = new NotificationBloc(); await notificationBloc.initialize(); runApp( MultiBlocProvider(providers: [ BlocProvider.value(value: notificationBloc), ], child: MyApp()), ); }
I’m providing the same notification bloc instance to MyApp() because we may want to navigate or change the state of the whole application when the notification is tapped. Right?
Here is my main.dart file.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
NotificationBloc notificationBloc = new NotificationBloc();
await notificationBloc.initialize();
runApp(
MultiBlocProvider(providers: [
BlocProvider.value(value: notificationBloc),
], child: MyApp()),
);
}
class MyApp extends StatefulWidget {
const MyApp({Key key}) : super(key: key);
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
BlocProvider.of<NotificationBloc>(context)
.checkForLaunchedNotifications();
}
@override
Widget build(BuildContext context) {
return BlocListener<NotificationBloc, NotificationState>(
listener: (context, state) {
if (state is IndexedNotification) {
UiUtilities.showSnack(
context, "Here you can navigate to index ${state.index}");
} else if (state is NotificationErrorState) {
UiUtilities.showSnack(context, state.error);
}
},
child: MaterialApp(
theme: bindTheme,
onGenerateRoute: router.generateRoute,
initialRoute: RouterConstants.myGuidRoute,
),
);
}
}
Here I’m listening to state changes for NotificationBloc as I don’t want to rebuild the whole widget when the state changes. I just wanted to navigate to another screen, wanted to add events to other blocs, etc.
Now if you see didChangeDepedencies() method we’re identifying launch information and change the state accordingly. This is critical to handle because we don’t get a chance to handle app launch in main().
Pheww!! That’s how we can create NotificationBloc. Thanks for bearing with me.
Also, take a moment to explore this blog post on Flutter InitialRoute: Understanding the Role of the Slash “/”