Skip to content
Back to archipelago.banuacoder.com

Feature SDKs

How to add push notifications to a Flutter app with FCM ​

Free Reference for push_notification_sdk: every variable, option and package it generates. Command: archipelago generate push_notification_sdk

Generate the Push Notification SDK, configure Firebase, request permission, read the device token, and handle tap navigation from a notification.

What you'll learn ​

  • Generating the Push Notification SDK with the FCM provider
  • Adding OneSignal with its separate Pro brick
  • Configuring Firebase on Android and iOS
  • Requesting permission and retrieving the device token
  • Handling tap-driven navigation from a notification
  • Creating an Android notification channel and targeting the tap trampoline
  • Tiering messages, setting quiet hours and showing lock-screen-safe text

Prerequisites ​

  • An existing Archipelago monorepo (see Monorepo Scaffolding)
  • A Firebase project for FCM
  • A OneSignal account and a Pro subscription, only if you add OneSignal

Step 1: Generate the Push Notification SDK ​

bash
archipelago generate push_notification_sdk

The brick has one variable, includeFcm. It must be true. Generation stops if you set it to false, because this brick always includes FCM. OneSignal is a separate brick (see Step 5).

json
{
  "includeFcm": true
}
bash
archipelago generate push_notification_sdk --config push_notification_config.json

Step 2: Understand the Generated Structure ​

text
features/push_notification/
├── push_notification_api/           # PushNotificationSDK, models, policy port
├── push_notification_impl/          # Orchestrator, policy and its store
└── push_notification_fcm_impl/      # FCM handler

Your app code and other features depend only on push_notification_api.

The post-generation hook registers the FCM module first, then PushNotificationGlobalModule, in lib/di/injector.dart. The order matters. The global module reads every provider handler when it is built. It stays an eager SDK, because building it attaches the message and tap listeners at boot, before the first push arrives.

Step 3: Configure FCM on Android ​

  1. Run flutterfire configure, or download google-services.json from the Firebase console and place it at:

    apps/<appName>/android/app/google-services.json
  2. Create a default notification channel (required on Android 8 and later). The brick does not generate it:

    kotlin
    // apps/<appName>/android/app/src/main/kotlin/.../MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
    
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val channel = NotificationChannel(
          "default",
          "General",
          NotificationManager.IMPORTANCE_DEFAULT,
        )
        getSystemService(NotificationManager::class.java)
          .createNotificationChannel(channel)
      }
    }
  3. Target the tap trampoline. The brick writes NotificationActivity.kt next to MainActivity.kt and declares it in AndroidManifest.xml, so a cold-start tap does not run heavy work in MainActivity.onCreate. Put "click_action": "<your.package.name>.NOTIFICATION_TAP" in the FCM data payload, where the package name is package_name in archipelago.yaml. MainActivity from the generated project already forwards ACTION_VIEW intents to the deeplink channels.

Step 4: Configure FCM on iOS ​

  1. Download GoogleService-Info.plist from the Firebase console and place it at:

    apps/<appName>/ios/Runner/GoogleService-Info.plist
  2. Add no FirebaseApp.configure() call. bootstrap.dart starts Firebase from Dart, in a guarded call before initializeInjector(). Without Firebase config, the app still boots and this vendor is skipped.

  3. In Xcode, enable these capabilities for the Runner target:

    • Push Notifications
    • Background Modes → Remote notifications

Step 5: Add OneSignal (Optional, Pro) ​

OneSignal is a separate brick. It adds a second provider next to FCM. Generate push_notification_sdk first. The OneSignal brick stops with an error if it is missing.

bash
archipelago generate push_notification_onesignal

The brick has one prompt, oneSignalAppId. Copy it from your OneSignal dashboard under Settings, then Keys and IDs. It is not a secret. The handler calls OneSignal.initialize itself, so you add nothing to main.dart. If you left the ID empty, set it in onesignal_push_notification_handler.dart.

The hook patches the di_modules list so every handler registers before the orchestrator. It touches no AndroidManifest.xml, Info.plist or Gradle file. Follow the OneSignal Flutter setup guide for the Firebase key (Android) and the APNs key (iOS). Enable the same Xcode capabilities as for FCM.

OneSignal stays dormant until the user grants marketing consent.

Step 6: Request Permission and Get the Token ​

In a post-launch initializer or your onboarding flow. Take the SDK by constructor:

dart
// Request system permission (shows the OS prompt on first call)
final permission = await pushSDK.requestPermission();

// Retrieve the device token for your backend
final token = await pushSDK.getToken(); // PushToken?, or null if it fails
if (token != null) {
  await userRepository.registerDeviceToken(token);
}

Step 7: Handle Tap-Driven Navigation ​

When a user taps a notification, the SDK emits on tapStream. Listen once, after the router is ready. A post-launch initializer is a good place, because it receives the router:

dart
pushSDK.tapStream.listen((notification) {
  if (notification.route != null) {
    router.push(RouteUri.parse(notification.route!));
  }
});

The notification.route value comes from a route key in the data payload. Your backend controls where deep links land.

Step 8: Subscribe to Topics ​

FCM has native topics. OneSignal has none, so its handler simulates topics with tags:

dart
// Subscribe the user to the "premium" topic
await pushSDK.subscribeToTopic('premium');
// OneSignal: adds the tag topic_premium = true

// Unsubscribe
await pushSDK.unsubscribeFromTopic('premium');
// OneSignal: removes the tag topic_premium

With OneSignal, segment your audiences by the topic_* tag keys.

Step 9: Tier Messages, Quiet Hours and the Lock Screen ​

Every foreground message passes the notification policy before it is shown (full rules and flowchart: push_notification_sdk).

1. Tag each message on the server with a tier in the data payload:

json
{
  "message": {
    "token": "<FCM_TOKEN>",
    "notification": { "title": "Weekend sale", "body": "20% off this weekend" },
    "data": { "tier": "marketing", "route": "/offers" }
  }
}

Use critical for security alerts, transactional for receipts and codes, and marketing for promotions. Marketing needs marketing consent, respects quiet hours and is capped at 3 per 24 hours. A message without a tier behaves as before the policy: shown unchanged, never capped, never redacted. To redact a message of any tier, add "sensitive": "true".

2. Let the user set quiet hours. The brick ships a settings page at /notification-settings with quiet hours and a Promotions switch (the marketing opt-out). Home's Settings tile opens it. To change the values from your own code:

dart
await getIt<PushNotificationSDK>().setQuietHours(
  const QuietHours(startMinute: 22 * 60, endMinute: 7 * 60), // 22:00-07:00
);

Marketing that arrives in quiet hours is dropped. To deliver it when quiet hours end instead, add the deferral display:

bash
archipelago generate local_notification_display

3. Display sensitive messages with their public text. The local_notification_display brick above does this step for you. Without it, messages tagged critical or transactional, or sent with "sensitive": "true", arrive on foregroundStream with publicTitle / publicBody set. Show them with your local display and use the public text as the lock-screen version:

dart
getIt<PushNotificationSDK>().foregroundStream.listen((n) {
  showLocalNotification(
    title: n.title,
    body: n.body,
    lockScreenTitle: n.publicTitle ?? n.title, // Android publicVersion
    lockScreenBody: n.publicBody ?? n.body,
  );
});

If your backend sends a sensitive message with the full text in data['detail_title'] / data['detail_body'] and lock-screen-safe text as the visible title and body, n.title / n.body are the full text and n.publicTitle / n.publicBody are your safe text, so the same code works.

showLocalNotification is your own wrapper (for example around flutter_local_notifications). Messages that arrive while the app is in the background are shown by the OS directly, so keep amounts and personal data out of the title and body you send. The policy does not run for taps.

Common Customizations ​

GoalWhat to do
Multiple notification channels (Android)Create more NotificationChannel instances with distinct IDs
Handle foreground notificationsListen to foregroundStream on PushNotificationSDK (policy-approved messages only)
Change caps or lock-screen textInstall your own DefaultNotificationPolicy(caps: ..., lockScreenTitle: ...) via NotificationPolicyRegistry.instance.install after the push module runs
Send token to backend on refreshListen to tokenRefreshStream and re-register
Apply the policy on your backend tooSend notificationPreferences().toJson() (time zone, quiet hours, marketing consent) with the token; see the backend guide
Use APNs auth key instead of p12Upload the .p8 key in Firebase console → Project Settings → iOS app
Custom notification icon (Android)Set com.google.firebase.messaging.default_notification_icon in AndroidManifest.xml

Next Steps ​