← Articles

Push notifications in Flutter with FCM

By John · 4 May 20251 view

Push notifications in Flutter require Firebase Cloud Messaging (FCM) for delivery and native platform setup for permissions. Here is the complete setup including background message handling.

Setup

dependencies:
  firebase_messaging: ^15.0.0
  firebase_core: ^3.0.0
  flutter_local_notifications: ^17.0.0  # For customizing notification appearance

iOS configuration

  1. Enable Push Notifications capability in Xcode (Signing & Capabilities → + → Push Notifications)
  2. Enable Background Modes → Remote notifications
  3. Upload APNs key in Firebase Console → Project Settings → Cloud Messaging → APNs Auth Key
<!-- ios/Runner/Info.plist -->
<key>FirebaseAppDelegateProxyEnabled</key>
<false/>  <!-- Manage token registration yourself -->

Android configuration

Android 13+ requires explicit notification permission. FCM works without it but notifications are silently blocked:

dependencies:
  permission_handler: ^11.3.0
if (Platform.isAndroid) {
  final status = await Permission.notification.status;
  if (!status.isGranted) {
    await Permission.notification.request();
  }
}

Initialization

Future<void> initPushNotifications() async {
  final messaging = FirebaseMessaging.instance;

  // Request permission (iOS/macOS/web)
  final settings = await messaging.requestPermission(
    alert: true,
    badge: true,
    sound: true,
    provisional: false, // True = deliver quietly without asking
  );

  if (settings.authorizationStatus != AuthorizationStatus.authorized) {
    return; // User denied
  }

  // Get the FCM token (send to your backend for targeted notifications)
  final token = await messaging.getToken();
  await saveTokenToBackend(token!);

  // Listen for token refresh
  messaging.onTokenRefresh.listen(saveTokenToBackend);

  // Set up message handlers
  _setupMessageHandlers();
}

Future<void> saveTokenToBackend(String token) async {
  await api.post('/users/push-token', body: {'token': token});
}

Handling messages

Messages arrive in three scenarios:

void _setupMessageHandlers() {
  // 1. App in FOREGROUND: message received
  FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

  // 2. App in BACKGROUND: user taps notification
  FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);

  // 3. App TERMINATED: user taps notification to open app
  _checkInitialMessage();
}

void _handleForegroundMessage(RemoteMessage message) {
  // FCM doesn't show a notification when the app is in foreground
  // Use flutter_local_notifications to show one manually
  _showLocalNotification(message);
}

void _handleNotificationTap(RemoteMessage message) {
  final link = message.data['deep_link'] as String?;
  if (link != null) {
    router.go(link);
  }
}

Future<void> _checkInitialMessage() async {
  final initial = await FirebaseMessaging.instance.getInitialMessage();
  if (initial != null) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _handleNotificationTap(initial);
    });
  }
}

Background message handler

The background handler must be a top-level function (not a class method):

// Top-level function
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  // Handle background message (update local data, show notification)
  print('Background message: ${message.messageId}');
}

// In main():
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  runApp(const MyApp());
}

Local notifications (foreground)

final _localNotifications = FlutterLocalNotificationsPlugin();

Future<void> initLocalNotifications() async {
  const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
  const iosSettings = DarwinInitializationSettings();

  await _localNotifications.initialize(
    const InitializationSettings(android: androidSettings, iOS: iosSettings),
    onDidReceiveNotificationResponse: (response) {
      // Handle tap on local notification
      if (response.payload != null) {
        router.go(response.payload!);
      }
    },
  );
}

void _showLocalNotification(RemoteMessage message) {
  final notification = message.notification;
  if (notification == null) return;

  _localNotifications.show(
    notification.hashCode,
    notification.title,
    notification.body,
    NotificationDetails(
      android: AndroidNotificationDetails(
        'default_channel',
        'Default',
        importance: Importance.high,
        priority: Priority.high,
        icon: '@drawable/ic_notification',
      ),
      iOS: const DarwinNotificationDetails(),
    ),
    payload: message.data['deep_link'] as String?,
  );
}

Sending notifications from backend (Node.js)

const admin = require('firebase-admin');

await admin.messaging().send({
  token: userFcmToken,
  notification: {
    title: 'Order shipped!',
    body: 'Your order #1234 is on its way.',
  },
  data: {
    deep_link: '/orders/order-1234',
    order_id: 'order-1234',
  },
  apns: {
    payload: { aps: { sound: 'default', badge: 1 } },
  },
  android: {
    priority: 'high',
    notification: { sound: 'default' },
  },
});

Common pitfalls

Background handler not a top-level function. FCM runs the background handler in a separate Dart isolate. If it's a method on a class, it throws. Must be a top-level or static function.

Sending to stale tokens. FCM tokens change when users reinstall the app or clear data. Listen to onTokenRefresh and update your backend. When FCM returns messaging/registration-token-not-registered, remove the token.

Not calling getInitialMessage(). If a user taps a notification while the app is terminated, onMessageOpenedApp doesn't fire. Always check getInitialMessage() on startup.

Sign in to like, dislike, or report.

Comments

No comments yet. Be the first!

Sign in to leave a comment.