appcraft_notifications 0.0.1
appcraft_notifications: ^0.0.1 copied to clipboard
Firebase Cloud Messaging and local notifications behind one API: foreground copies, taps, cold start and the FCM token, with no backend assumptions.
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.