appcraft_notifications

Pub Version License: MIT

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:

  1. read the previously stored token;
  2. await register(newToken) — register the new one first;
  3. only after it succeeds, revoke(oldToken) if there was one and it differs;
  4. 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 notification block: 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_notifications exports its Windows details unconditionally, and those import dart:io. Web builds are unaffected, flutter build web --wasm included.

Libraries

appcraft_notifications
Firebase Cloud Messaging and local notifications behind one API.