Mobile Development

Flutter Push Notifications: Panduan Lengkap FCM & Local Notifications

Tutorial lengkap push notifications di Flutter — Firebase Cloud Messaging (FCM), local notifications, background handlers, foreground handling, dan strategi notifikasi production-ready

1. Pengenalan Push Notifications

Push Notification adalah pesan singkat yang dikirim dari server ke device user, bahkan ketika aplikasi tidak sedang aktif. Push notification merupakan salah satu fitur paling penting untuk menjaga engagement user di mobile apps.

Jenis Notifikasi di Flutter

Jenis Sumber Kapan Muncul Pustaka
FCM RemoteServer (Firebase Console, Cloud Functions, API)Kapan saja — app foreground, background, terminatedfirebase_messaging
LocalDari dalam app FlutterSaat user berinteraksi dengan appflutter_local_notifications
In-AppDari dalam app FlutterSaat app aktif di foregroundCustom widget / snackbar
Diagram: Alur Push Notification
┌──────────┐    ┌──────────────┐    ┌───────────────┐    ┌──────────┐
│  Server   │───→│  Firebase    │───→│  APNs / FCM   │───→│  Device  │
│ (Backend) │    │  Cloud Msg   │    │  (Platform)   │    │ (Flutter)│
└──────────┘    └──────────────┘    └───────────────┘    └──────────┘
                     │                                        │
                     │   ┌─────────────────────┐              │
                     │   │  App State:          │              │
                     │   │  • Foreground         │←─────────────┤
                     │   │    → onMessage        │              │
                     │   │  • Background         │←─────────────┤
                     │   │    → onMessageOpened  │              │
                     │   │  • Terminated         │←─────────────┤
                     │   │    → onMessageOpened  │              │
                     │   └─────────────────────┘              │
                     │                                        │
                     └──────── 3 States Handler ──────────────┘

Platform Setup Overview

Platform Service Konfigurasi Tambahan
AndroidFCM (Firebase Cloud Messaging)google-services.json, firebase_options.dart
iOSAPNs via FCMApple Developer Certificate, GoogleService-Info.plist
WebFCM (Firebase Cloud Messaging)Service Worker registration
macOS/WindowsTidak didukung langsung

2. Arsitektur FCM di Flutter

Untuk memahami bagaimana push notification bekerja di Flutter, penting untuk memahami arsitektur FCM dan bagaimana plugin firebase_messaging berinteraksi dengan platform layer.

Diagram: Arsitektur FCM di Flutter
┌─────────────────────────────────────────────────────────────┐
│                       FLUTTER DART CODE                      │
│                                                             │
│  ┌────────────────────────────────────────────────────────┐ │
│  │                 firebase_messaging                      │ │
│  │                                                        │ │
│  │  • FirebaseMessaging.instance.getToken()               │ │
│  │  • FirebaseMessaging.onMessage.listen(...)             │ │
│  │  • FirebaseMessaging.onMessageOpenedApp.listen(...)    │ │
│  │  • FirebaseMessaging.onBackgroundMessage(...)          │ │
│  │  • requestPermission()                                 │ │
│  │  • subscribeToTopic() / unsubscribeFromTopic()         │ │
│  └───────────────────────┬────────────────────────────────┘ │
│                          │                                    │
│  ┌───────────────────────┴────────────────────────────────┐ │
│  │              flutter_local_notifications                 │ │
│  │                                                        │ │
│  │  • LocalNotificationService.show(...)                   │ │
│  │  • Scheduled / periodic notifications                   │ │
│  │  • Custom notification body & actions                   │ │
│  └───────────────────────┬────────────────────────────────┘ │
└──────────────────────────┼──────────────────────────────────┘
                           │
        ┌──────────────────┴──────────────────────┐
        │          PLATFORM CHANNEL               │
        │     (MethodChannel / EventChannel)       │
        └──────────────────┬──────────────────────┘
                           │
        ┌──────────────────┴──────────────────────┐
        │           NATIVE CODE                   │
        │  ┌──────────────┐  ┌────────────────┐   │
        │  │   Android    │  │     iOS        │   │
        │  │  (Kotlin/     │  │  (Swift/       │   │
        │  │   Java)       │  │   ObjC)        │   │
        │  │              │  │                │   │
        │  │  FCM SDK     │  │  APNs + FCM   │   │
        │  └──────────────┘  └────────────────┘   │
        └─────────────────────────────────────────┘

Tiga State Aplikasi & Handler

State App Handler Deskripsi
ForegroundFirebaseMessaging.onMessageApp aktif di layar — handler di Dart langsung dipanggil
BackgroundFirebaseMessaging.onMessageOpenedAppApp di background — user tap notifikasi → app dibuka
TerminatedFirebaseMessaging.onMessageOpenedAppApp tertutup — user tap notifikasi → app dimulai
⚠️ Perbedaan Penting: Foreground vs Background

Di foreground, notifikasi FCM TIDAK akan menampilkan popup notifikasi secara otomatis — Anda harus handle sendiri (tampilkan in-app notification). Di background dan terminated, notifikasi akan ditampilkan oleh sistem secara default. Ini adalah salah satu kesalahan umum pemula Flutter.

3. Setup Firebase Cloud Messaging

3.1 Install Dependencies

YAML — pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  # Firebase Core & Messaging
  firebase_core: ^3.8.1
  firebase_messaging: ^15.2.1

  # Local Notifications (opsional, untuk in-app notifications)
  flutter_local_notifications: ^18.0.1

  # Untuk mengelola permission iOS
  permission_handler: ^11.3.1

3.2 Setup Android

Pastikan file google-services.json sudah ada di android/app/. Tambahkan konfigurasi di android/app/build.gradle:

Gradle — android/app/build.gradle
android {
    compileSdkVersion 34

    defaultConfig {
        // ...
        minSdkVersion 21  // Minimum untuk firebase_messaging
    }

    dependencies {
        // Google Services plugin
        classpath 'com.google.gms:google-services:4.4.2'
    }
}

// Di bagian apply plugin:
apply plugin: 'com.google.gms.google-services'

Tambahkan service di android/app/src/main/AndroidManifest.xml:

XML — AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

  <!-- Izin notifikasi untuk Android 13+ -->
  <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

  <application>

    <!-- FCM Default Notification Channel -->
    <meta-data
      android:name="com.google.firebase.messaging.default_notification_channel_id"
      android:value="high_importance_channel" />

    <meta-data
      android:name="com.google.firebase.messaging.default_notification_icon"
      android:resource="@drawable/ic_notification" />

    <meta-data
      android:name="com.google.firebase.messaging.default_notification_color"
      android:resource="@color/notification_color" />

    <!-- Background message handler -->
    <service
      android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingBackgroundService"
      android:exported="false">
    </service>

  </application>
</manifest>

3.3 Setup iOS

Untuk iOS, Anda perlu mengaktifkan Push Notification capability di Xcode:

  1. Buka ios/Runner.xcworkspace di Xcode
  2. Klik target Runner → tab Signing & Capabilities
  3. Klik "+ Capability" → pilih Push Notifications
  4. Pastikan Background ModesRemote notifications dicentang
  5. Upload APNs Authentication Key ke Firebase Console
Dart — iOS Permission Request
// iOS memerlukan permission yang lebih eksplisit
//firebase_messaging akan otomatis meminta permission
//saat getToken() dipanggil di iOS

final settings = await _messaging.requestPermission(
  alert: true,        // Tampilkan notifikasi
  badge: true,        // Update badge number
  sound: true,        // Mainkan suara
  provisional: false, // langsung request full permission
);

// Status permission:
// AuthorizationStatus.authorized → diizinkan
// AuthorizationStatus.denied → ditolak
// AuthorizationStatus.provisional → sementara
// AuthorizationStatus.notDetermined → belum diputuskan

4. Permission & Token Management

4.1 Full Permission Service

Dart — services/permission_service.dart
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:permission_handler/permission_handler.dart';

class NotificationPermissionService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  /// Minta izin notifikasi — dengan penanganan khusus per platform
  Future<bool> requestPermission() async {
    if (Platform.isAndroid) {
      return await _requestAndroidPermission();
    } else if (Platform.isIOS) {
      return await _requestIOSPermission();
    }
    return false;
  }

  Future<bool> _requestAndroidPermission() async {
    // Android 13+ (API 33) memerlukan permission POST_NOTIFICATIONS
    final status = await Permission.notification.status;

    if (status.isGranted) return true;

    if (status.isPermanentlyDenied) {
      // User menolak permanen — arahkan ke settings
      print('Permission ditolak permanen. Buka Settings.');
      return false;
    }

    final result = await Permission.notification.request();
    return result.isGranted;
  }

  Future<bool> _requestIOSPermission() async {
    final settings = await _messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
      provisional: false,
    );

    return settings.authorizationStatus == AuthorizationStatus.authorized;
  }

  /// Cek status permission saat ini
  Future<NotificationPermissionStatus> checkPermissionStatus() async {
    if (Platform.isAndroid) {
      final status = await Permission.notification.status;
      if (status.isGranted) return NotificationPermissionStatus.granted;
      if (status.isDenied) return NotificationPermissionStatus.denied;
      if (status.isPermanentlyDenied) {
        return NotificationPermissionStatus.permanentlyDenied;
      }
    } else if (Platform.isIOS) {
      final settings = await _messaging.getNotificationSettings();
      switch (settings.authorizationStatus) {
        case AuthorizationStatus.authorized:
          return NotificationPermissionStatus.granted;
        case AuthorizationStatus.denied:
          return NotificationPermissionStatus.denied;
        default:
          return NotificationPermissionStatus.notDetermined;
      }
    }
    return NotificationPermissionStatus.denied;
  }
}

enum NotificationPermissionStatus {
  granted,
  denied,
  permanentlyDenied,
  notDetermined,
}

4.2 Token Management

Dart — Token Management
class TokenService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;
  final FirebaseFirestore _db = FirebaseFirestore.instance;

  /// Ambil FCM token dan simpan ke Firestore
  Future<String?> getAndSaveToken(String userId) async {
    try {
      // Ambil token
      final token = await _messaging.getToken();

      if (token != null) {
        await _saveToken(userId, token);
        print('FCM Token: $token');
      }

      // Listen token refresh (token bisa berubah sewaktu-waktu)
      _messaging.onTokenRefresh.listen((newToken) {
        print('FCM Token refreshed: $newToken');
        _saveToken(userId, newToken);
      });

      return token;
    } catch (e) {
      print('Error getting token: $e');
      return null;
    }
  }

  /// Simpan token ke Firestore dengan deduplikasi
  Future<void> _saveToken(String userId, String token) async {
    final userRef = _db.collection('users').doc(userId);

    // Cek apakah token sudah ada
    final userDoc = await userRef.get();
    final existingTokens = List<String>.from(
      userDoc.data()?['fcmTokens'] ?? [],
    );

    if (!existingTokens.contains(token)) {
      // Tambah token baru
      await userRef.update({
        'fcmTokens': FieldValue.arrayUnion([token]),
      });

      // Bersihkan token lama (simpan max 5 token per user)
      if (existingTokens.length >= 5) {
        final tokensToRemove = existingTokens.sublist(
          0, existingTokens.length - 4,
        );
        await userRef.update({
          'fcmTokens': FieldValue.arrayRemove(tokensToRemove),
        });
      }
    }
  }

  /// Hapus token saat logout
  Future<void> removeToken(String userId) async {
    final token = await _messaging.getToken();
    if (token != null) {
      await _db.collection('users').doc(userId).update({
        'fcmTokens': FieldValue.arrayRemove([token]),
      });
    }

    // Delete token dari device
    await _messaging.deleteToken();
  }
}

5. Handling Notifikasi Foreground

Ketika aplikasi aktif di foreground, notifikasi FCM tidak akan menampilkan popup secara otomatis. Anda perlu mendengarkan stream onMessage dan menampilkan notifikasi sendiri — bisa menggunakan flutter_local_notifications atau custom in-app widget.

5.1 Foreground Handler dengan Local Notifications

Dart — services/notification_service.dart
import 'dart:convert';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:firebase_core/firebase_core.dart';

class NotificationService {
  static final FlutterLocalNotificationsPlugin _localPlugin =
      FlutterLocalNotificationsPlugin();

  static const String _channelId = 'high_importance_channel';
  static const String _channelName = 'Notifikasi Penting';
  static const String _channelDesc = 'Channel untuk notifikasi penting';

  /// Inisialisasi semua handler notifikasi
  static Future<void> initialize(String userId) async {
    // 1. Setup local notifications
    await _initializeLocalNotifications();

    // 2. Setup FCM handlers
    await _setupFCMHandlers();

    // 3. Buat notification channel (Android)
    await _createNotificationChannel();

    print('NotificationService initialized');
  }

  /// Setup local notifications
  static Future<void> _initializeLocalNotifications() async {
    // Android initialization
    const androidSettings = AndroidInitializationSettings(
      '@mipmap/ic_launcher',
    );

    // iOS initialization
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: false, // Sudah minta via FCM
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    const initSettings = InitializationSettings(
      android: androidSettings,
      iOS: iosSettings,
    );

    await _localPlugin.initialize(
      initSettings,
      onDidReceiveNotificationResponse: _onNotificationTap,
    );
  }

  /// Setup FCM foreground handler
  static Future<void> _setupFCMHandlers() async {
    // ── FOREGROUND HANDLER ──
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      print('=== FOREGROUND MESSAGE ===');
      print('Title: ${message.notification?.title}');
      print('Body: ${message.notification?.body}');
      print('Data: ${message.data}');

      // Tampilkan notifikasi lokal
      _showLocalNotification(
        title: message.notification?.title ?? 'Notifikasi',
        body: message.notification?.body ?? '',
        payload: jsonEncode(message.data),
      );
    });

    // ── BACKGROUND/TERMINATED HANDLER ──
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      print('=== NOTIFICATION TAPPED ===');
      print('Route: ${message.data['route']}');
      _handleNotificationNavigation(message.data);
    });
  }

  /// Tampilkan notifikasi lokal
  static Future<void> _showLocalNotification({
    required String title,
    required String body,
    String? payload,
  }) async {
    const androidDetails = AndroidNotificationDetails(
      _channelId,
      _channelName,
      channelDescription: _channelDesc,
      importance: Importance.high,
      priority: Priority.high,
      icon: '@mipmap/ic_launcher',
      color: Color(0xFF2196F3),
      styleInformation: BigTextStyleInformation(''),
    );

    const iosDetails = DarwinNotificationDetails(
      presentAlert: true,
      presentBadge: true,
      presentSound: true,
    );

    const details = NotificationDetails(
      android: androidDetails,
      iOS: iosDetails,
    );

    await _localPlugin.show(
      DateTime.now().millisecondsSinceEpoch ~/ 1000, // ID unik
      title,
      body,
      details,
      payload: payload,
    );
  }

  /// Handler saat user tap notifikasi lokal
  static void _onNotificationTap(NotificationResponse response) {
    if (response.payload != null) {
      final data = jsonDecode(response.payload!);
      _handleNotificationNavigation(data);
    }
  }

  /// Navigasi berdasarkan data notifikasi
  static void _handleNotificationNavigation(Map<String, dynamic> data) {
    final route = data['route'];
    if (route != null) {
      // Navigator.pushNamed(navigatorKey.currentContext!, route);
      print('Navigate to: $route');
    }
  }
}

5.2 In-App Notification Banner

Dart — Custom In-App Banner Widget
import 'package:flutter/material.dart';

class InAppNotificationBanner extends StatefulWidget {
  final String title;
  final String body;
  final VoidCallback? onTap;
  final Duration duration;

  const InAppNotificationBanner({
    super.key,
    required this.title,
    required this.body,
    this.onTap,
    this.duration = const Duration(seconds: 4),
  });

  @override
  State<InAppNotificationBanner> createState() =>
      _InAppNotificationBannerState();

  /// Helper method untuk menampilkan banner
  static void show(BuildContext context, {
    required String title,
    required String body,
    VoidCallback? onTap,
  }) {
    final overlay = Overlay.of(context);
    late OverlayEntry entry;

    entry = OverlayEntry(
      builder: (context) => Positioned(
        top: MediaQuery.of(context).padding.top + 8,
        left: 16,
        right: 16,
        child: InAppNotificationBanner(
          title: title,
          body: body,
          onTap: () {
            entry.remove();
            onTap?.call();
          },
        ),
      ),
    );

    overlay.insert(entry);

    // Auto remove setelah 4 detik
    Future.delayed(const Duration(seconds: 4), () {
      if (entry.mounted) entry.remove();
    });
  }
}

class _InAppNotificationBannerState extends State<InAppNotificationBanner>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _slideAnimation;
  late Animation<double> _fadeAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );

    _slideAnimation = Tween<double>(begin: -1.0, end: 0.0).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeOut),
    );

    _fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeOut),
    );

    _controller.forward();

    // Auto dismiss
    Future.delayed(widget.duration, () {
      if (mounted) {
        _controller.reverse().then((_) {
          if (mounted) {
            ScaffoldMessenger.of(context).hideCurrentSnackBar();
          }
        });
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.translate(
          offset: Offset(0, _slideAnimation.value * 100),
          child: Opacity(
            opacity: _fadeAnimation.value,
            child: Material(
              elevation: 8,
              borderRadius: BorderRadius.circular(12),
              color: Theme.of(context).colorScheme.surface,
              child: InkWell(
                onTap: widget.onTap,
                borderRadius: BorderRadius.circular(12),
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Row(
                    children: [
                      const Icon(Icons.notifications_active,
                        color: Colors.blue, size: 32),
                      const SizedBox(width: 12),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Text(widget.title,
                              style: const TextStyle(
                                fontWeight: FontWeight.bold, fontSize: 14)),
                            const SizedBox(height: 4),
                            Text(widget.body,
                              style: TextStyle(
                                fontSize: 13,
                                color: Theme.of(context)
                                  .colorScheme.onSurfaceVariant),
                              maxLines: 2,
                              overflow: TextOverflow.ellipsis),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

6. Handling Notifikasi Background & Terminated

Saat app di background atau terminated, notifikasi ditangani oleh onMessageOpenedApp. Untuk background processing saat app terminated, Anda juga bisa menggunakan onBackgroundMessage.

6.1 Background Handler

Dart — background_handler.dart (top-level!)
// background_handler.dart
// ⚠️ HARUS di top-level function, bukan dalam class!
// ⚠️ Tidak boleh mengakses instance variables

import 'dart:convert';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

// Inisialisasi local notification untuk background
final FlutterLocalNotificationsPlugin _localPlugin =
    FlutterLocalNotificationsPlugin();

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(
  RemoteMessage message,
) async {
  // Inisialisasi Firebase
  await Firebase.initializeApp();

  print('=== BACKGROUND MESSAGE ===');
  print('Message ID: ${message.messageId}');
  print('Title: ${message.notification?.title}');
  print('Body: ${message.notification?.body}');
  print('Data: ${message.data}');
  print('Priority: ${message.priority}');

  // Contoh: simpan ke local database
  await _saveNotificationLocally(message);

  // Contoh: lakukan background fetch
  if (message.data['type'] == 'new_order') {
    await _processNewOrder(message.data);
  }
}

Future<void> _saveNotificationLocally(RemoteMessage message) async {
  // Simpan ke Hive, SQLite, atau SharedPreferences
  final notificationData = {
    'id': message.messageId,
    'title': message.notification?.title ?? '',
    'body': message.notification?.body ?? '',
    'data': message.data,
    'receivedAt': DateTime.now().toIso8601String(),
    'read': false,
  };

  // Contoh: simpan ke SharedPreferences
  // final prefs = await SharedPreferences.getInstance();
  // final notifications = prefs.getStringList('notifications') ?? [];
  // notifications.add(jsonEncode(notificationData));
  // await prefs.setStringList('notifications', notifications);

  print('Notification saved locally: ${notificationData['title']}');
}

Future<void> _processNewOrder(Map<String, dynamic> data) async {
  // Proses order baru di background
  // Bisa sync ke server, update local database, dll.
  print('Processing new order: ${data['orderId']}');
}

// ─── main.dart ───
// void main() async {
//   WidgetsFlutterBinding.ensureInitialized();
//   await Firebase.initializeApp(
//     options: DefaultFirebaseOptions.currentPlatform,
//   );
//
//   // Register background handler SEBELUM runApp
//   FirebaseMessaging.onBackgroundMessage(
//     firebaseMessagingBackgroundHandler,
//   );
//
//   runApp(const MyApp());
// }

6.2 Deep Linking dari Notifikasi

Dart — Deep Link Handler
class DeepLinkHandler {
  static final GlobalKey<NavigatorState> navigatorKey =
      GlobalKey<NavigatorState>();

  /// Handle navigasi dari notifikasi
  static void handleNotificationData(Map<String, dynamic> data) {
    final type = data['type'];
    final id = data['id'];
    final route = data['route'];

    if (route != null) {
      // Route spesifik
      navigatorKey.currentState?.pushNamed(route, arguments: id);
      return;
    }

    // Handle berdasarkan tipe
    switch (type) {
      case 'chat_message':
        _openChat(data['chatId']);
        break;
      case 'new_order':
        _openOrderDetail(data['orderId']);
        break;
      case 'friend_request':
        _openFriendRequests();
        break;
      case 'payment_success':
        _openPaymentHistory(data['paymentId']);
        break;
      case 'promo':
        _openPromoPage(data['promoId']);
        break;
      default:
        // Buka halaman notifikasi/default
        _openNotificationList();
    }
  }

  static void _openChat(String? chatId) {
    navigatorKey.currentState?.pushNamed('/chat', arguments: chatId);
  }

  static void _openOrderDetail(String? orderId) {
    navigatorKey.currentState?.pushNamed('/order', arguments: orderId);
  }

  static void _openFriendRequests() {
    navigatorKey.currentState?.pushNamed('/friend-requests');
  }

  static void _openPaymentHistory(String? paymentId) {
    navigatorKey.currentState?.pushNamed('/payment', arguments: paymentId);
  }

  static void _openPromoPage(String? promoId) {
    navigatorKey.currentState?.pushNamed('/promo', arguments: promoId);
  }

  static void _openNotificationList() {
    navigatorKey.currentState?.pushNamed('/notifications');
  }
}

// Di main.dart, set navigatorKey:
// MaterialApp(
//   navigatorKey: DeepLinkHandler.navigatorKey,
//   ...
// )

7. Local Notifications

Local notifications ditampilkan dari dalam aplikasi tanpa melalui server. Berguna untuk pengingat, alarm, scheduled notifications, atau in-app alerts.

7.1 Setup Local Notifications

Dart — Local Notification Setup
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tz_data;

class LocalNotificationService {
  static final FlutterLocalNotificationsPlugin _plugin =
      FlutterLocalNotificationsPlugin();

  /// Inisialisasi local notifications
  static Future<void> initialize() async {
    // Inisialisasi timezone
    tz_data.initializeTimeZones();

    // Konfigurasi Android
    const androidSettings = AndroidInitializationSettings(
      '@mipmap/ic_launcher',
    );

    // Konfigurasi iOS
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: false,
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    // Inisialisasi
    const initSettings = InitializationSettings(
      android: androidSettings,
      iOS: iosSettings,
    );

    await _plugin.initialize(
      initSettings,
      onDidReceiveNotificationResponse: (response) {
        print('Notification tapped: ${response.payload}');
      },
    );

    // Buat notification channels (Android)
    await _createChannels();
  }

  /// Buat notification channels
  static Future<void> _createChannels() async {
    final androidPlugin = _plugin.resolvePlatformSpecificImplementation
        <AndroidFlutterLocalNotificationsPlugin>();

    if (androidPlugin != null) {
      // Channel: High Importance
      await androidPlugin.createNotificationChannel(
        const AndroidNotificationChannel(
          'high_importance_channel',
          'Notifikasi Penting',
          description: 'Channel untuk notifikasi penting',
          importance: Importance.high,
        ),
      );

      // Channel: Low Importance
      await androidPlugin.createNotificationChannel(
        const AndroidNotificationChannel(
          'low_importance_channel',
          'Info',
          description: 'Channel untuk info umum',
          importance: Importance.low,
        ),
      );

      // Channel: Scheduled
      await androidPlugin.createNotificationChannel(
        const AndroidNotificationChannel(
          'scheduled_channel',
          'Pengingat',
          description: 'Channel untuk pengingat terjadwal',
          importance: Importance.high,
        ),
      );
    }
  }
}

7.2 Tampilkan Notifikasi Instan

Dart — Show Instant Notification
  /// Tampilkan notifikasi instan
  static Future<void> showNotification({
    required int id,
    required String title,
    required String body,
    String? payload,
    Importance importance = Importance.high,
  }) async {
    const androidDetails = AndroidNotificationDetails(
      'high_importance_channel',
      'Notifikasi Penting',
      channelDescription: 'Channel untuk notifikasi penting',
      importance: Importance.high,
      priority: Priority.high,
      icon: '@mipmap/ic_launcher',
      // Multi-line style
      styleInformation: BigTextStyleInformation(''),
      // Tombol aksi
      actions: [
        AndroidNotificationAction('action_1', 'Lihat'),
        AndroidNotificationAction('action_2', 'Dismiss'),
      ],
    );

    const iosDetails = DarwinNotificationDetails(
      presentAlert: true,
      presentBadge: true,
      presentSound: true,
      interruptionLevel: InterruptionLevel.timeSensitive,
    );

    const details = NotificationDetails(
      android: androidDetails,
      iOS: iosDetails,
    );

    await _plugin.show(id, title, body, details, payload: payload);
  }

7.3 Scheduled Notifications

Dart — Scheduled Notifications
  /// Jadwalkan notifikasi untuk waktu tertentu
  static Future<void> scheduleNotification({
    required int id,
    required String title,
    required String body,
    required DateTime scheduledTime,
    String? payload,
  }) async {
    final tz.TZDateTime tzScheduled =
        tz.TZDateTime.from(scheduledTime, tz.local);

    await _plugin.zonedSchedule(
      id,
      title,
      body,
      tzScheduled,
      const NotificationDetails(
        android: AndroidNotificationDetails(
          'scheduled_channel',
          'Pengingat',
          channelDescription: 'Channel untuk pengingat terjadwal',
          importance: Importance.high,
          priority: Priority.high,
        ),
        iOS: DarwinNotificationDetails(
          presentAlert: true,
          presentBadge: true,
          presentSound: true,
        ),
      ),
      androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
      matchDateTimeComponents: null, // null = sekali saja
      payload: payload,
    );
  }

  /// Jadwalkan notifikasi berulang (harian)
  static Future<void> scheduleDailyNotification({
    required int id,
    required String title,
    required String body,
    required int hour,
    required int minute,
  }) async {
    final now = tz.TZDateTime.now(tz.local);
    var scheduled = tz.TZDateTime(tz.local, now.year, now.month, now.day,
      hour, minute);

    // Jika waktu sudah lewat hari ini, jadwalkan besok
    if (scheduled.isBefore(now)) {
      scheduled = scheduled.add(const Duration(days: 1));
    }

    await _plugin.zonedSchedule(
      id,
      title,
      body,
      scheduled,
      const NotificationDetails(
        android: AndroidNotificationDetails(
          'scheduled_channel',
          'Pengingat',
          importance: Importance.high,
          priority: Priority.high,
        ),
        iOS: DarwinNotificationDetails(),
      ),
      androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
      matchDateTimeComponents: DateTimeComponents.time, // Setiap hari
    );
  }

  /// Contoh: Pengingat obat harian
  static Future<void> scheduleMedicineReminder({
    required int id,
    required String medicineName,
    required int hour,
    required int minute,
  }) async {
    await scheduleDailyNotification(
      id: id,
      title: '💊 Waktu Minum Obat',
      body: 'Jangan lupa minum $medicineName',
      hour: hour,
      minute: minute,
    );
  }

  /// Batalkan notifikasi tertentu
  static Future<void> cancelNotification(int id) async {
    await _plugin.cancel(id);
  }

  /// Batalkan semua notifikasi
  static Future<void> cancelAllNotifications() async {
    await _plugin.cancelAll();
  }
}

7.4 Recurring Notifications (Periodic)

Dart — Periodic Notifications
  /// Kirim notifikasi periodic
  static Future<void> showPeriodicNotification({
    required int id,
    required String title,
    required String body,
    RepeatInterval interval = RepeatInterval.hourly,
  }) async {
    await _plugin.periodicallyShow(
      id,
      title,
      body,
      interval,
      const NotificationDetails(
        android: AndroidNotificationDetails(
          'low_importance_channel',
          'Info',
          importance: Importance.low,
          priority: Priority.low,
        ),
        iOS: DarwinNotificationDetails(),
      ),
    );
  }

  // Contoh penggunaan:
  // Tampilkan reminder setiap jam 9 pagi
  // await showPeriodicNotification(
  //   id: 1,
  //   title: '⏰ Jam 9 Pagi',
  //   body: 'Sudahkah Anda berolahraga hari ini?',
  //   interval: RepeatInterval.hourly,
  // );

8. Custom Notification UI

Android mendukung custom notification layout menggunakan BigPictureStyle, BigTextStyle, dan MediaStyle. Berikut cara membuat notifikasi dengan gambar (rich notification).

Dart — Rich Notification dengan Gambar
import 'dart:typed_data';
import 'package:http/http.dart' as http;

class RichNotificationService {
  /// Notifikasi dengan gambar
  static Future<void> showImageNotification({
    required String title,
    required String body,
    required String imageUrl,
  }) async {
    // Download gambar
    final response = await http.get(Uri.parse(imageUrl));
    final Uint8List imageBytes = response.bodyBytes;

    // Simpan gambar lokal untuk notification
    final tempDir = await getTemporaryDirectory();
    final file = File('${tempDir.path}/notification_image.jpg');
    await file.writeAsBytes(imageBytes);

    final bigPicture = FilePathAndroidBitmap(file.path);

    final androidDetails = AndroidNotificationDetails(
      'image_channel',
      'Notifikasi Gambar',
      channelDescription: 'Notifikasi dengan gambar',
      importance: Importance.high,
      priority: Priority.high,
      styleInformation: BigPictureStyleInformation(
        bigPicture,
        contentTitle: title,
        summaryText: body,
      ),
    );

    await _plugin.show(
      DateTime.now().millisecondsSinceEpoch ~/ 1000,
      title,
      body,
      NotificationDetails(android: androidDetails),
    );
  }

  /// Notifikasi dengan tombol aksi
  static Future<void> showActionNotification({
    required String title,
    required String body,
  }) async {
    const androidDetails = AndroidNotificationDetails(
      'action_channel',
      'Notifikasi Aksi',
      importance: Importance.high,
      priority: Priority.high,
      actions: [
        AndroidNotificationAction(
          'accept',
          '✅ Terima',
          showsUserInterface: true,
        ),
        AndroidNotificationAction(
          'decline',
          '❌ Tolak',
          cancelsNotification: true,
        ),
      ],
    );

    await _plugin.show(
      DateTime.now().millisecondsSinceEpoch ~/ 1000,
      title,
      body,
      const NotificationDetails(android: androidDetails),
    );
  }

  /// Notifikasi dengan progress bar (download)
  static Future<void> showProgressNotification({
    required int id,
    required String title,
    required int progress,
    required int maxProgress,
  }) async {
    final androidDetails = AndroidNotificationDetails(
      'progress_channel',
      'Download',
      channelDescription: 'Progress download',
      importance: Importance.low,
      priority: Priority.low,
      ongoing: true, // Tidak bisa di-dismiss
      showsProgress: true,
    );

    // Tidak ada cara langsung set progress di flutter_local_notifications
    // Tapi kita bisa update notifikasi dengan progress
    final progress = (100 * (progress / maxProgress)).round();

    await _plugin.show(
      id,
      title,
      '$progress% selesai',
      NotificationDetails(android: androidDetails),
    );
  }
}

9. Topic Messaging & Targeting

9.1 Subscribe/Unsubscribe Topic

Dart — Topic Management
class TopicService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  /// Subscribe ke topik
  Future<void> subscribeTopic(String topic) async {
    await _messaging.subscribeToTopic(topic);
    print('Subscribed to: $topic');
  }

  /// Unsubscribe dari topik
  Future<void> unsubscribeTopic(String topic) async {
    await _messaging.unsubscribeFromTopic(topic);
    print('Unsubscribed from: $topic');
  }

  /// Subscribe ke multiple topik
  Future<void> subscribeMultiple(List<String> topics) async {
    for (final topic in topics) {
      await subscribeTopic(topic);
    }
  }

  /// Contoh: User pilih preferensi notifikasi
  Future<void> updatePreferences({
    required String userId,
    required Map<String, bool> preferences,
  }) async {
    for (final entry in preferences.entries) {
      if (entry.value) {
        await subscribeTopic('${userId}_${entry.key}');
      } else {
        await unsubscribeTopic('${userId}_${entry.key}');
      }
    }

    // Simpan preferences ke Firestore
    await FirebaseFirestore.instance.collection('users').doc(userId).update({
      'notificationPreferences': preferences,
    });
  }

  // Contoh topik yang umum digunakan:
  // • "news" → semua berita
  // • "promos" → promo dan diskon
  // • "android" / "ios" → per platform
  // • "region_jakarta" → per wilayah
  // • "premium_users" → berdasarkan subscription
}

// Kirim notifikasi ke topik via Cloud Functions:
// admin.messaging().send({
//   notification: { title: 'Promo!', body: 'Diskon 50%' },
//   topic: 'promos',
// });

9.2 Conditional Topic Subscription UI

Dart — Notification Settings Page
class NotificationSettingsPage extends StatefulWidget {
  const NotificationSettingsPage({super.key});

  @override
  State<NotificationSettingsPage> createState() =>
      _NotificationSettingsPageState();
}

class _NotificationSettingsPageState
    extends State<NotificationSettingsPage> {
  final _topicService = TopicService();

  Map<String, bool> _preferences = {
    'orders': true,
    'promotions': true,
    'news': false,
    'chat': true,
    'system': true,
  };

  @override
  void initState() {
    super.initState();
    _loadPreferences();
  }

  Future<void> _loadPreferences() async {
    final uid = FirebaseAuth.instance.currentUser?.uid;
    if (uid == null) return;

    final doc = await FirebaseFirestore.instance
        .collection('users').doc(uid).get();

    final savedPrefs = doc.data()?['notificationPreferences'];
    if (savedPrefs != null) {
      setState(() {
        _preferences = Map<String, bool>.from(savedPrefs);
      });
    }
  }

  Future<void> _updatePreference(String key, bool value) async {
    final uid = FirebaseAuth.instance.currentUser?.uid;
    if (uid == null) return;

    setState(() {
      _preferences[key] = value;
    });

    await _topicService.updatePreferences(
      userId: uid,
      preferences: _preferences,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Pengaturan Notifikasi')),
      body: ListView(
        children: [
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text('Pilih notifikasi yang ingin Anda terima:',
              style: TextStyle(fontSize: 16)),
          ),
          _buildSwitch('orders', 'Pesanan & Pembayaran',
            'Update status pesanan dan pembayaran'),
          _buildSwitch('promotions', 'Promo & Diskon',
            'Penawaran khusus dan promo terbaru'),
          _buildSwitch('news', 'Berita & Artikel',
            'Artikel dan berita terbaru'),
          _buildSwitch('chat', 'Pesan',
            'Pesan dari pengguna lain'),
          _buildSwitch('system', 'Sistem',
            'Pembaruan aplikasi dan info penting'),
          const Divider(),
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextButton.icon(
              onPressed: () async {
                await Permission.notification.request();
              },
              icon: const Icon(Icons.settings),
              label: const Text('Buka Pengaturan Sistem'),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSwitch(String key, String title, String subtitle) {
    return SwitchListTile(
      title: Text(title),
      subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
      value: _preferences[key] ?? false,
      onChanged: (value) => _updatePreference(key, value),
    );
  }
}

10. Best Practices & Testing

10.1 Best Practices

💡 Best Practices Push Notifications
  • Jangan spam — batasi jumlah notifikasi yang dikirim (1-2 per hari maksimal)
  • Personalisasi — gunakan nama user dan konten relevan
  • Deep linking — setiap notifikasi harus mengarahkan ke konten spesifik
  • Segmentasi — gunakan topik dan target audience untuk notifikasi yang relevan
  • Timing — kirim notifikasi di waktu yang tepat (jam 9-11 pagi atau 7-9 malam)
  • Permission handling — selalu cek status permission dan berikan fallback
  • Badge management — update badge number secara akurat
  • Token cleanup — bersihkan token yang invalid secara berkala

10.2 Testing Push Notifications

Terminal — Testing Commands
# ─── Testing dari Firebase Console ───
# 1. Buka Firebase Console → Cloud Messaging
# 2. Compose notification → masukkan title, body
# 3. Pilih target (topic, segment, atau token spesifik)
# 4. Kirim!

# ─── Testing via Cloud Functions ───
# Deploy test function:
firebase deploy --only functions:sendTestNotification

# ─── Testing via cURL (HTTP v1 API) ───
# Kirim ke device spesifik
curl -X POST \
  https://fcm.googleapis.com/v1/projects/YOUR_PROJECT/messages:send \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "message": {
      "token": "DEVICE_FCM_TOKEN",
      "notification": {
        "title": "Test Notification",
        "body": "Ini adalah test notifikasi"
      },
      "data": {
        "route": "/home",
        "type": "test"
      },
      "android": {
        "priority": "high",
        "notification": {
          "channel_id": "high_importance_channel",
          "click_action": "FLUTTER_NOTIFICATION_CLICK"
        }
      }
    }
  }'

# ─── Testing via Firebase CLI ───
firebase messaging:send --token=DEVICE_TOKEN \
  --title="Test" \
  --body="Hello from CLI"

10.3 Debug Tips

Dart — Debug Helper
class NotificationDebugger {
  /// Tampilkan semua informasi debug notifikasi
  static Future<void> printDebugInfo() async {
    final messaging = FirebaseMessaging.instance;

    // FCM Token
    final token = await messaging.getToken();
    print('═══ FCM Debug Info ═══');
    print('Token: $token');
    print('Token length: ${token?.length}');

    // Permission Status
    final settings = await messaging.getNotificationSettings();
    print('Authorization: ${settings.authorizationStatus}');
    print('Alert: ${settings.alert}');
    print('Badge: ${settings.badge}');
    print('Sound: ${settings.sound}');

    // APNs Token (iOS only)
    final apnsToken = await messaging.getAPNSToken();
    print('APNs Token: $apnsToken');

    // Link to Firebase Console for testing
    print('');
    print('📋 Untuk testing dari Firebase Console:');
    print('   1. Buka Firebase Console → Cloud Messaging');
    print('   2. Compose → paste token di atas');
    print('   3. Kirim test notification');
    print('═══════════════════════════');
  }

  /// Cek apakah device sudah terdaftar di Firebase
  static Future<bool> isDeviceRegistered() async {
    final token = await FirebaseMessaging.instance.getToken();
    return token != null && token.isNotEmpty;
  }
}

// Panggil di initState() halaman debug:
// NotificationDebugger.printDebugInfo();
⚠️ Common Pitfalls
  • Foreground notifications tidak muncul otomatis — harus handle sendiri dengan onMessage
  • Background handler harus top-level function — tidak boleh di dalam class
  • Token berubah — selalu listen onTokenRefresh
  • Android 13+ memerlukan POST_NOTIFICATIONS — wajib request permission
  • Notification channel wajib dibuat di Android 8+ sebelum bisa menampilkan notifikasi
  • iOS perlu APNs key yang di-upload ke Firebase Console

11. Quiz Pemahaman

Uji pemahaman Anda tentang Flutter Push Notifications:

Pertanyaan 1: Mengapa notifikasi FCM tidak muncul saat app di foreground?

a) Karena FCM belum didukung di foreground
b) Karena sistem tidak menampilkan popup — developer harus handle sendiri via onMessage
c) Karena permission belum diberikan
d) Karena bug di flutter_messaging

Pertanyaan 2: Mengapa background handler harus berupa top-level function?

a) Karena itu hanya convention
b) Karena background handler berjalan di isolate terpisah — tidak bisa mengakses class instance
c) Karena Dart tidak support method di class
d) Karena Firebase memerlukannya

Pertanyaan 3: Apa yang dilakukan FieldValue.arrayUnion saat menyimpan FCM token?

a) Menghapus token lama
b) Menambah token baru jika belum ada (deduplikasi otomatis)
c) Menimpa semua token
d) Mengurutkan token

Pertanyaan 4: Apa yang perlu dilakukan untuk notification channel di Android 8+?

Pertanyaan 5: Bagaimana cara mengirim notifikasi ke semua user yang tertarik topik tertentu?

🔍 Zoom
100%
🎨 Tema