Tutorial · 20 min · advanced

Notifications, local & push

Permission, local alerts, chat-style banners with avatars on both platforms, and the FCM token pipeline — firebase_messaging's job, over FFI.

What you'll build — running on device.

Notifications span two plugins that work together: dartnative_notifications for everything local — permission, alerts, the chat-style banner (iOS Communication Notifications / Android MessagingStyle-like avatars) — and dartnative_firebase for FCM push, on BOTH platforms. You’ll wire the full pipeline: ask, show, style, then fetch a real push token and watch foreground pushes stream in. It’s the DartNative playground’s notifications demo, carried into the tutorial byte-for-byte. The local half needs zero configuration; push needs a Firebase project (the README lists the steps).

What you need

  • A project from Your first DartNative app
  • dartnative_notifications: ^1.0.0 and dartnative_firebase: ^1.0.0
  • For the FCM sections: your GoogleService-Info.plist, the Push Notifications + Background Modes → Remote notifications capabilities, and a physical device (APNs skips simulators)

Step 0 — Push configuration

The local half runs with zero setup — skip ahead if that’s all you need. For FCM push, four one-time steps:

  1. Set a bundle identifier unique to your team (push needs an explicit App ID — the scaffold’s com.example.* placeholder is already claimed and fails team registration), register an iOS app with that ID in your Firebase project, and drop its GoogleService-Info.plist into ios/Runner/ — the Runner target already references it there.
  2. Add the Push Notifications capability to the Runner target (Xcode: Signing & Capabilities → + Capability). By hand that’s a Runner.entitlements with aps-environment = development, wired via the CODE_SIGN_ENTITLEMENTS build setting. Miss this and the token request times out — APNs registration silently never completes.
  3. For background delivery, also tick Background Modes → Remote notifications.
  4. Upload an APNs auth key in the Firebase console (Project settings → Cloud Messaging).

On Android, drop google-services.json into android/app/ and apply the Google Services Gradle plugin; on Android 13+ the POST_NOTIFICATIONS permission must be in the manifest and granted at runtime (the demo’s permission button requests it). Also ship a notification_icon.png (white silhouette on transparency, five densities) — Android tints status-bar icons by alpha only, so the full-colour launcher fallback renders as a solid black square; the plugin README has the sizes and a generator script.

Step 1 — Set up in main()

Both plugins install a native callback surface — the object the OS calls when a token arrives or a banner is tapped (a delegate on iOS, the activity-event hub on Android). Callbacks want to exist before anything can fire them, so setup lives in main(), before the first frame — the SAME code on both platforms:

Future<void> main() async {
  DartNativePluginRegistrant.registerAll();
  // Firebase: loads its symbols + inits the default app from the platform
  // config (GoogleService-Info.plist / google-services.json), then
  // installs the FCM delegate — done in main() so the token is ready
  // before any screen asks for it.
  try {
    await Firebase.initializeApp();
    FirebaseMessaging.setup();
  } catch (e) {
    print('Firebase init failed — continuing without push: $e');
  }
  try {
    DartNativeNotifications.setup(onTap: (payload) {
      // A tapped notification lands here — route on the payload.
      print('notification tapped: $payload');
    });
  } catch (e) {
    print('Notifications init failed: $e');
  }
  runApp(const NotificationsDemo());
}

Firebase.initializeApp() reads GoogleService-Info.plist on iOS and the google-services.json-derived config on Android; FirebaseMessaging.setup() installs the FCM delegate. Because both happen before runApp, the token is ready before any screen asks, and a notification tapped from a cold start still reaches your onTap on either platform. Each init gets its own try/catch deliberately — a missing config file should cost you push, not the whole app.

Step 2 — Permission

Nothing shows until the user says yes, so this is the first button on the screen:

// dartnative_notifications handles permission on both platforms:
//   iOS  — UNUserNotificationCenter.requestAuthorization (also covers APNs/FCM)
//   Android — POST_NOTIFICATIONS runtime permission (API 33+)
final granted = await DartNativeNotifications.requestPermission();
setState(() => _permissionGranted = granted);

One call, both platforms. On iOS the same authorization covers local alerts and remote APNs/FCM pushes — you don’t ask twice. On Android 13+ it maps to the POST_NOTIFICATIONS runtime permission; on older Android there is no runtime permission, so it reports the current notifications-enabled state without showing a dialog.

Step 3 — A local notification

DartNativeNotifications.show(
  id: 'demo-default-1',
  title: 'dartnative',
  body: 'Hello from dartnative 👋  This is a default notification.',
  payload: '{}',
);

show() posts straight to the platform’s notification service — UNUserNotificationCenter on iOS, a NotificationCompat build on Android — no scheduling ceremony for the immediate case. The id is a handle you can cancel() later; the payload string round-trips untouched to your setup(onTap:) callback when the user taps the banner — put your routing information there, as JSON or whatever you like. Background the app after tapping Show Default Notification to see the banner (a foregrounded app shows notifications more quietly).

Step 4 — The chat-style banner

This is the show-off step, and it works on both platforms. On iOS 15+ these are Communication Notifications — the banner style Messages and WhatsApp get, the sender’s avatar composed into the banner by iOS via INSendMessageIntent. On Android the same call renders the sender as the title with the avatar as a circle in the notification:

DartNativeNotifications.showChat(
  id: 'demo-chat-1',
  senderName: 'dartnative Bot',
  body: 'Hello from dartnative 👋  This is a Communication Notification.',
  avatar: avatar, // Uint8List — raw JPEG/PNG bytes
  payload: '{}',
);

The tutorial fetches a real avatar over plain HttpClient and, if the network fails, passes an empty Uint8List instead — the banner then falls back to no custom avatar rather than blocking the send (on Android that fallback is your launcher icon, so the avatar slot never shows empty). On iOS versions before 15 it degrades to a plain notification. It’s the single most recognizable “this app is native” signal a chat app can send, and it’s one call.

Step 5 — The push token

The FCM registration token is the address your backend sends pushes to. Fetching it is one awaited call — the delegate you installed in Step 1 has been holding it since startup:

final token = await FirebaseMessaging.getToken();

If getToken times out: on iOS the diagnosis is almost always the Push Notifications capability missing from the Runner target; on Android, google-services.json missing from android/app/ or the Google Services Gradle plugin not applied. The tutorial catches TimeoutException and says so on screen.

Pushes that arrive while the app is open don’t bannerize; they stream to Dart instead. The tutorial subscribes once in initState and logs them live:

FirebaseMessaging.onMessage.listen((msg) {
  setState(() {
    _receivedMessages.insert(
      0,
      '[${msg.from}] ${msg.notificationTitle ?? ''}: '
      '${msg.notificationBody ?? '(no body)'}',
    );
  });
});

Two channels, no overlap: onMessage is foreground arrivals, setup(onTap:) is taps on banners (background or terminated). Send a test push from the Firebase Console to the token from getToken and watch it land in the list.

Why this is native

dartnative_firebase replaces firebase_messaging with a zero-channel FFI implementation of the same job — the delegate is real APNs/FCM plumbing, not a Dart-side simulation. And the Communication banner is a genuinely native surface Flutter plugins don’t reach: avatar, sender identity, and system chat styling composed by iOS itself — and on Android by the platform’s own notification pipeline, zero-glue: the runtime dispatches permission results and tap intents to the plugin automatically.

The finished code

In the public repo — dn create ., dn run. The screen is the playground’s notifications demo byte-identical (plus the playground’s shared UI kit, also verbatim); lib/main.dart adds only the thin entry from Step 1. When the playground screen improves, this tutorial inherits it by copying the files again.

Open the finished code →