- Pengenalan Push Notifications
- Arsitektur FCM di Flutter
- Setup Firebase Cloud Messaging
- Permission & Token Management
- Handling Notifikasi Foreground
- Handling Notifikasi Background & Terminated
- Local Notifications
- Custom Notification UI
- Topic Messaging & Targeting
- Best Practices & Testing
- Quiz Pemahaman
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 Remote | Server (Firebase Console, Cloud Functions, API) | Kapan saja — app foreground, background, terminated | firebase_messaging |
| Local | Dari dalam app Flutter | Saat user berinteraksi dengan app | flutter_local_notifications |
| In-App | Dari dalam app Flutter | Saat app aktif di foreground | Custom widget / snackbar |
┌──────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────┐
│ 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 |
|---|---|---|
| Android | FCM (Firebase Cloud Messaging) | google-services.json, firebase_options.dart |
| iOS | APNs via FCM | Apple Developer Certificate, GoogleService-Info.plist |
| Web | FCM (Firebase Cloud Messaging) | Service Worker registration |
| macOS/Windows | Tidak 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.
┌─────────────────────────────────────────────────────────────┐
│ 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 |
|---|---|---|
| Foreground | FirebaseMessaging.onMessage | App aktif di layar — handler di Dart langsung dipanggil |
| Background | FirebaseMessaging.onMessageOpenedApp | App di background — user tap notifikasi → app dibuka |
| Terminated | FirebaseMessaging.onMessageOpenedApp | App tertutup — user tap notifikasi → app dimulai |
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
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:
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:
<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:
- Buka
ios/Runner.xcworkspacedi Xcode - Klik target Runner → tab Signing & Capabilities
- Klik "+ Capability" → pilih Push Notifications
- Pastikan Background Modes → Remote notifications dicentang
- Upload APNs Authentication Key ke Firebase Console
// 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
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
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
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
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
// 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
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
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
/// 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
/// 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)
/// 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).
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
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
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
- 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
# ─── 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
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();
- 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: