appcraft_notifications
Firebase Cloud Messaging and local notifications behind one API.
The package receives messages in all three states, shows a local copy of a message that arrives while the application is running, reports taps and hands over the FCM token. It makes no assumption about a backend: registering the token on a server, storing a notification history and navigating by a payload belong to the application, so the package fits any server contract.
Supported platforms: Android, iOS and the web.
Installation
dependencies:
appcraft_notifications: ^0.0.1
The application also needs firebase_core and firebase_messaging: it
initializes Firebase itself and declares its own background message handler.
Usage
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'orders',
'Orders',
description: 'Order status',
importance: Importance.max,
);
@pragma('vm:entry-point')
Future<void> handleBackgroundMessage(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Store the message here if the application keeps a history.
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FirebaseMessaging.onBackgroundMessage(handleBackgroundMessage);
final AppCraftNotifications notifications = await AppCraftNotifications.create(
androidChannel: channel,
androidIcon: 'ic_notification',
);
runApp(MyApp(notifications: notifications));
}
The order matters: Firebase is initialized first, the background handler is
registered before runApp, and create() is awaited — until it completes
messages are not being received. There is no public constructor and no global
instance: the application owns the object and passes it around itself.
notifications.messages.listen(showInApp); // arrived while running
notifications.taps.listen(navigateByData); // a tap on a notification
// The launch notification does not go through a stream — a broadcast stream
// does not buffer, and it is known before anyone can subscribe. Take it when
// the navigator already exists.
final AppCraftNotification? launch = notifications.takeLaunchNotification();
messages reports data-only messages as well, but a local copy is shown only
for a message that carries a title or a body — a data-only one does not become
an empty notification in the drawer. The application can show a notification of
its own in the same channel:
await notifications.show(
title: 'Order is ready',
body: 'Pick it up until 21:00',
data: <String, String>{'route': '/orders/1'},
);
dispose() cancels the subscriptions and closes the streams; after it the
instance is unusable and a new one can be created.
AppCraftNotification is flat: messageId, title, body,
data and receivedAt. RemoteMessage does not leak out — after a tap on a
locally shown copy and after a cold start it does not exist. By the FCM
protocol every value of data is a string; a nested structure comes as a
string with JSON inside, and the package decodes one level only and guesses
nothing.
Permission
final AuthorizationStatus status = await notifications.requestPermission();
Choose the moment yourself — the package never asks on its own. One call covers
both iOS and POST_NOTIFICATIONS on Android 13+. The system dialog is shown
once, and a denial on iOS is final: afterwards the permission can only be
granted in the system settings. Without a granted permission iOS issues no APNs
token, so getToken() returns null there.
getPermissionStatus() returns the current settings without asking the user.
Token
getToken() and tokenChanges are everything the package knows about the
token. On the web FCM issues it only for the Web Push certificate of the
project, so pass vapidKey to create(); without it getToken() throws a
StateError there.
Registering the token on a server belongs to the application, and the order of a rotation is what makes it correct:
- read the previously stored token;
await register(newToken)— register the new one first;- only after it succeeds,
revoke(oldToken)if there was one and it differs; - only then overwrite the stored value.
The reverse order leaves the user without notifications when a request fails; a postponed overwrite gives a natural retry on the next launch.
Notification history
The package stores nothing. An application that needs a history subscribes to
messages and writes on its own — into a database it already has.
Two requirements that no library can solve for you:
- Deduplicate. The same message reaches the background handler and then the
tap handler. The natural key is
messageId; insert ignoring a conflict. - Do not show a local notification from the background handler for a
message with a
notificationblock: in the background FCM has already shown it, and a local copy adds a second one to the drawer. It is admissible for a data-only message and only as a deliberate decision.
Setup
Android
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="orders" />
The value must be the id of the channel passed to create(), otherwise the
notifications FCM shows itself land in a different channel with a different
sound and importance. The channel id is a permanent technical key: Android
ignores a change of importance of an existing channel, and changing it requires
deleting the channel, which resets what the user has configured. The name is a
displayed string and can change freely.
androidIcon is the name of a drawable of the application, without a folder
and without an extension.
iOS
Upload the APNs key in the Firebase console. For data-only messages in the
background add remote-notification to UIBackgroundModes in Info.plist.
Web
final AppCraftNotifications notifications = await AppCraftNotifications.create(
androidChannel: channel, // not used on the web, but still required
androidIcon: 'ic_notification',
vapidKey: 'YOUR_WEB_PUSH_CERTIFICATE_KEY',
);
Put a web/firebase-messaging-sw.js in the application — copy the one from
example/web. It is the background handler of a web application: the Dart
function registered through FirebaseMessaging.onBackgroundMessage() is
never called on the web.
That worker is also what makes taps on background pushes visible. A tap on a
notification shown by Firebase happens while no Dart code is running, and
onMessageOpenedApp does not fire on the web, so the worker forwards it: to an
open tab as a message, and to a closed one as query parameters of the opened
URL. The package receives both through the same path as a tap on a notification
it showed itself — taps while the tab is open, takeLaunchNotification() when
the tap started the application.
Keep the shape of that file when editing it: the click handler is registered
before the Firebase imports on purpose, and it takes the click away from FCM
while still honouring fcm_options.link. Both reasons are in the comments of
the worker itself.
Nothing has to be registered for the local notifications: the plugin brings its
own service worker as an asset and registers it during create(). It needs the
Notification API and a granted permission — until both are there, showing is
skipped and, with debugLogs, the reason is written to the log.
What is not covered
- On Apple platforms a notification history is inherently incomplete: the background handler is not called for a notification-only message when the application is not running.
- On the web a message that arrives with the tab closed does not pass through
the package at all — it belongs to
firebase-messaging-sw.js, and so does storing it. Only the tap comes back, and only while the worker forwards it. - pub.flutter-io.cn marks the package as not WASM-compatible:
flutter_local_notificationsexports its Windows details unconditionally, and those importdart:io. Web builds are unaffected,flutter build web --wasmincluded.
Libraries
- appcraft_notifications
- Firebase Cloud Messaging and local notifications behind one API.