Flutter: FCM — How to Navigate to a Particular Screen After Tapping on Push Notification

Update: After upgrading flutter local notification to 12.0.3, there’s been a few changes in the code.

In this post, I’ll go over how to receive FCM Push Notifications and then push a particular page when the user taps on the notification in Flutter (no server-side code).

https://firebase.google.com/docs/cloud-messaging

iOS Setup

Apple is a very annoying company as a developer to deal with…and unlike Android, you need an actual device to test push notifications, and need to be registered in the Apple Developer Program as an Admin or an Account Holder.

  1. In XCode, go to Targets — Runner — Signing & Capabilities and press + to add Push Notifications. Also add Background Mode’s Background fetch and Remote notification.
XCode

2. In Apple Developer Member Center, go to Certificates, Identifiers & Profile Tab — Key Tab — add Apple Push Notification service (APN)’s key. Then, add that key in Firebase Console — Project Settings — Cloud Messaging — Apple app configuration.

3. The rest is pretty straight forward — official document.

Android Setup

In order to use Foreground Notification (when the push notification temporarily pops up at the top of the screen), you need the below meta-data in AndroidManifest.xml. As for the “high_importance_channel”, I just used the name given in Firebase official document and you can change it according to how you name it in platformChannelSpecifics (described further below in the post).

<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel" />

Initialize Firebase

First, add all the necessary packages.

flutter pub add firebase_messaging
flutter pub add firebase_core
flutter pub add flutter_local_notifications

I’m used to separating my files into a provider and service file, but obviously, that’s not necessary. As for initializing Firebase, you need to do it in main.

void main() async {
WidgetsFlutterBinding.ensureInitialized();
await FirebaseService.initializeFirebase();
runApp(const MyApp());
}
import 'dart:async';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

class FirebaseService {
static FirebaseMessaging? _firebaseMessaging;
static FirebaseMessaging get firebaseMessaging => FirebaseService._firebaseMessaging ?? FirebaseMessaging.instance;

static Future<void> initializeFirebase() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FirebaseService._firebaseMessaging = FirebaseMessaging.instance;
await FirebaseService.initializeLocalNotifications();
await FCMProvider.onMessage();
await FirebaseService.onBackgroundMsg();
}

Future<String?> getDeviceToken() async => await FirebaseMessaging.instance.getToken();

static FlutterLocalNotificationsPlugin _localNotificationsPlugin = FlutterLocalNotificationsPlugin();

static Future<void> initializeLocalNotifications() async {
final InitializationSettings _initSettings = InitializationSettings(
android: AndroidInitializationSettings("icon_name"),
iOS: DarwinInitializationSettings()
);
/// on did receive notification response = for when app is opened via notification while in foreground on android
await FirebaseService.localNotificationsPlugin.initialize(_initSettings, onDidReceiveNotificationResponse: FCMProvider.onTapNotification);
/// need this for ios foregournd notification
await FirebaseService.firebaseMessaging.setForegroundNotificationPresentationOptions(
alert: true, // Required to display a heads up notification
badge: true,
sound: true,
);
}

static NotificationDetails platformChannelSpecifics = NotificationDetails(
android: AndroidNotificationDetails(
"high_importance_channel", "High Importance Notifications", priority: Priority.max, importance: Importance.max,
),
);

// for receiving message when app is in background or foreground
static Future<void> onMessage() async {
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
if (Platform.isAndroid) {
// if this is available when Platform.isIOS, you'll receive the notification twice
await FirebaseService._localNotificationsPlugin.show(
0, message.notification!.title, message.notification!.body, FirebaseService.platformChannelSpecifics,
payload: message.data.toString(),
);
}
});
}

static Future<void> onBackgroundMsg() async {
FirebaseMessaging.onBackgroundMessage(FCMProvider.backgroundHandler);
}

}

I’ll go over all the methods in FirebaseService.initializeFirebase.

_firebaseMessaging: I read somewhere when I was making my previous FCM post, that it’s better to call FirebaseMessaging.instance only once. So, I initialize it in the initialize method and in case it didn’t get initialized, I made a getter that gives it the proper value if _firebaseMessaging is null.

initializeLocalNotifications: You need this in order to utilize Foreground Notification. As for Android, one argument you must pass is the icon logo file name, which should be in android/app/src/main/res/drawable.

The second argument passed is onSelectNotification, which is a callback executed when the user taps the notification (when the app is in the foreground). Without this, the app will only open and no further action will occur. This method receives the payload from onMessage’s FirebaseMessaging.onMessage.listen. (for android)

onMessage: called when you receive a notification when the app is in the foreground.

onBackgroundMsg: called when you receive a notification while the app is in the background or terminated.

Get/Check Device Token

Future<String?> getDeviceToken() async => await FirebaseService.firebaseMessaging.getToken();

There’s no correct answer to manage your device tokens, but here is a recommended way by Firebase. The method I chose is to save the device token on the user’s device with sqflite with a timestamp, and send it to the server. Every time the app is opened, if there’s a device token saved, it’s sent to the server. If the timestamp indicates that it’s been more than a month, the token is refreshed and a new token is sent to the server. (Below code is based on my sql file, which uses sqflite.

Future<String?> checkDeviceToken() async {
String? _deviceToken;
final bool _exists = await this._sqlService.tableExists(this._tableName);
if (_exists) {
final List<Json> _data = await this._sqlService.readData(this._tableName);
final DateTime _timeStamp = DateTime.parse(_data[0]["timeStamp"]);
if (_timeStamp.difference(DateTime.now()).inDays > 30) {
_deviceToken = await this._getDeviceToken();
if (_deviceToken == null) return null; // todo error handling
await this._updateDeviceToken(_deviceToken);
} else {
_deviceToken = _data[0]["deviceToken"];
}
} else {
_deviceToken = await this._getDeviceToken();
if (_deviceToken == null) return null; // todo error handling
await this._saveDeviceToken(_deviceToken);
}
return _deviceToken;
}

Future<String?> _getDeviceToken() async => await FirebaseService.firebaseMessaging.getToken();

Future<void> _saveDeviceToken(String deviceToken) async {
final String _createSql = "CREATE TABLE ${this._tableName}(deviceToken TEXT PRIMARY KEY NOT NULL, timeStamp TEXT NOT NULL)";
final List<Object> _values = [deviceToken, DateTime.now().toIso8601String()];
final String _insertSql = "INSERT INTO ${this._tableName}(deviceToken, timeStamp) VALUES(?, ?)";
await this._sqlService.saveData(tableName: this._tableName, createSql: _createSql, insertSql: _insertSql, values: _values);
}

Future<void> _updateDeviceToken(String deviceToken) async {
final String _updateSql = "UPDATE ${this._tableName} SET deviceToken = ?, timeStamp = ?";
final List<Object> _values = [deviceToken, DateTime.now().toIso8601String()];
await this._sqlService.updateData(tableName: this._tableName, updateSql: _updateSql, values: _values);
}

Receiving Notification

Unfortunately, since I’m not making server-side code, I cannot show how message.data works because Firebase’s test notification only allows message.notification.body and message.notification.title. You would need to work with your back-end developer to test more precise notifications out.

There are 3 ways to receive push notifications and open the app. (1) When app is in the foreground, (2) when app is in the background, and (3) when app is terminated.

When App is in the Foreground — Android

The default action when the user taps on the notification is to just open the app. However, sometimes you want the user to see a specific page. The back-end developer can provide such information in message.data. I made a provider to get the information, and in order to use Navigator.of(context).push, in the first screen, I initialized the provider’s BuildContext variable.

@override
void init() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
FCMProvider.setContext(context);
});
}
import 'package:firebase_messaging/firebase_messaging.dart' show FirebaseMessaging, RemoteMessage;
import 'package:flutter/widgets.dart';
import 'package:pops/helpers/custom_types.dart';

import '../views/store_detail/store_detail_page.dart';

class FCMProvider with ChangeNotifier {
static BuildContext? _context;

static void setContext(BuildContext context) => FCMProvider._context = context;

/// when app is in the foreground
static Future<void> onTapNotification(NotificationResponse? response) async {
if (FCMProvider._context == null || response?.payload == null) return;
final Json _data = FCMProvider.convertPayload(response!.payload!);
if (_data.containsKey(...)){
await Navigator.of(FCMProvider._context!).push(...);
}
}

static Json convertPayload(String payload){
final String _payload = payload.substring(1, payload.length - 1);
List<String> _split = [];
_payload.split(",")..forEach((String s) => _split.addAll(s.split(":")));
Json _mapped = {};
for (int i = 0; i < _split.length + 1; i++) {
if (i % 2 == 1) _mapped.addAll({_split[i-1].trim().toString(): _split[i].trim()});
}
return _mapped;
}

static Future<void> onMessage() async {
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
if (FCMProvider._refreshNotifications != null) await FCMProvider._refreshNotifications!(true);
// if this is available when Platform.isIOS, you'll receive the notification twice
if (Platform.isAndroid) {
await FirebaseService.localNotificationsPlugin.show(
0, message.notification!.title,
message.notification!.body,
FirebaseService.platformChannelSpecifics,
payload: message.data.toString(),
);
}
});
}

static Future<void> backgroundHandler(RemoteMessage message) async {

}
}

The onTapNotification method is executed when the app is in the foreground, which is the callback method in localNotificationsPlugin.initialize’s onSelectNotification. onTapNotification receives the message.data.toString() as payload (because you can only put a string as payload) and convert the payload back to a map.

When App is in the Background (Android) & When App is in Foreground/Background (iOS)

In the first page, I put the following code in initState, which seems to work when the app is in the background for android, and when app is in the foreground/background for iOS.

Stream<RemoteMessage> _stream = FirebaseMessaging.onMessageOpenedApp;
_stream.listen((RemoteMessage event) async {
if (event.data != null) {
await Navigator.of(context).push(...);
}
});

When App is Terminated

When the app is terminated, you need to get the message in main.dart’s main method and if you try to get it anywhere else, it’ll fail. I passed this message down to the first page, and in initState, took the appropriate action.

// main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await FirebaseService.initializeFirebase();
final RemoteMessage? _message = await FirebaseService.firebaseMessaging.getInitialMessage();
runApp(const MyApp(message: _message));
}

// first page that's opened when app turned on
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (this.widget.message != null) {
Future.delayed(const Duration(milliseconds: 1000), () async {
await Navigator.of(context).pushNamed(...);
});
}
});
}

Backend

In order for you to receive notifications with sound on iOS, you need the following in the cloud console: (refer to github)

"apns: { 
"payload": {
"aps": {
"sound": default
}
}
}

Hope this helps! Happy coding.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store