Push Notification & Deep Link #
Push notification dan deep link sering diimplementasikan bersamaan karena saling melengkapi: notifikasi membawa pengguna kembali ke app, dan deep link memastikan mereka langsung dibawa ke halaman yang relevan — bukan cuma ke home screen. Keduanya melibatkan lapisan native (FCM, APNs) dan lapisan Flutter, dan penanganannya berbeda tergantung state app saat notifikasi diterima.
Firebase Cloud Messaging (FCM) — Setup #
# pubspec.yaml
dependencies:
firebase_core: ^3.8.1
firebase_messaging: ^15.1.6
flutter_local_notifications: ^17.2.4 # untuk tampilkan notifikasi di foreground
# Setup Firebase (gunakan FlutterFire CLI)
dart pub global activate flutterfire_cli
flutterfire configure --project=nama-project-firebase
Inisialisasi #
// lib/core/notifications/notification_service.dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
// Handler background -- WAJIB top-level function
@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
debugPrint('[FCM Background] ${message.messageId}: ${message.notification?.title}');
// Tampilkan local notification dari background jika perlu
await NotificationService.showLocalNotification(message);
}
class NotificationService {
static final _messaging = FirebaseMessaging.instance;
static final _localNotifications = FlutterLocalNotificationsPlugin();
static const _androidChannel = AndroidNotificationChannel(
'high_importance_channel',
'Notifikasi Penting',
description: 'Channel untuk notifikasi penting dari app',
importance: Importance.max,
playSound: true,
);
static Future<void> initialize() async {
// 1. Set background handler SEBELUM app siap
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
// 2. Minta izin notifikasi
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false,
);
debugPrint('Izin notifikasi: ${settings.authorizationStatus}');
// 3. Setup local notifications (untuk tampilkan di foreground)
await _localNotifications.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(
requestAlertPermission: false, // sudah diminta via FCM
requestBadgePermission: false,
requestSoundPermission: false,
),
),
onDidReceiveNotificationResponse: _onLocalNotificationTap,
);
// 4. Buat Android notification channel
await _localNotifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(_androidChannel);
// 5. Setup handler untuk berbagai state app
_setupMessageHandlers();
// 6. Ambil FCM token
final token = await _messaging.getToken();
debugPrint('FCM Token: $token');
// Kirim token ke backend kamu
await _sendTokenToBackend(token!);
// Refresh token saat diperbarui
_messaging.onTokenRefresh.listen(_sendTokenToBackend);
}
Tiga State App Saat Notifikasi Tiba #
static void _setupMessageHandlers() {
// STATE 1: FOREGROUND -- app sedang terbuka dan aktif
// FCM tidak tampilkan notifikasi otomatis -- harus tampilkan manual
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
debugPrint('[FCM Foreground] ${message.notification?.title}');
showLocalNotification(message); // tampilkan sebagai local notification
});
// STATE 2: BACKGROUND -- app diminimize tapi masih hidup
// FCM menampilkan notifikasi secara otomatis di system tray
// Handler dipanggil saat pengguna TAP notifikasi
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
debugPrint('[FCM Background Tap] ${message.data}');
_handleNotificationTap(message.data);
});
// STATE 3: TERMINATED -- app sudah ditutup sepenuhnya
// Cek apakah app dibuka dari notifikasi
_checkInitialMessage();
}
static Future<void> _checkInitialMessage() async {
// Notifikasi yang membuka app dari state terminated
final initialMessage = await _messaging.getInitialMessage();
if (initialMessage != null) {
debugPrint('[FCM Terminated Tap] ${initialMessage.data}');
// Perlu delay kecil agar router sudah siap
await Future.delayed(const Duration(milliseconds: 500));
_handleNotificationTap(initialMessage.data);
}
}
// Tampilkan notifikasi saat app di foreground
static Future<void> showLocalNotification(RemoteMessage message) async {
final notification = message.notification;
if (notification == null) return;
await _localNotifications.show(
notification.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
_androidChannel.id,
_androidChannel.name,
channelDescription: _androidChannel.description,
importance: Importance.max,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: jsonEncode(message.data), // bawa data agar bisa di-handle saat tap
);
}
// Navigasi berdasarkan data notifikasi
static void _handleNotificationTap(Map<String, dynamic> data) {
final type = data['type'] as String?;
final id = data['id'] as String?;
switch (type) {
case 'produk':
AppRouter.router.push('/produk/$id');
break;
case 'pesanan':
AppRouter.router.push('/pesanan/$id');
break;
case 'promo':
AppRouter.router.push('/promo');
break;
default:
AppRouter.router.push('/');
}
}
// Handler tap local notification
static void _onLocalNotificationTap(NotificationResponse response) {
if (response.payload == null) return;
final data = jsonDecode(response.payload!) as Map<String, dynamic>;
_handleNotificationTap(data);
}
static Future<void> _sendTokenToBackend(String token) async {
// Kirim ke API backend kamu
await ApiClient.instance.post('/device-tokens', body: {'fcm_token': token});
}
}
Deep Link — Buka Halaman Spesifik dari URL #
Deep link memungkinkan URL eksternal (dari email, SMS, website) membuka halaman tertentu di dalam app.
Jenis deep link:
Custom URL Scheme -- myapp://produk/123
Lebih mudah setup, tapi tidak bisa dibuka di browser
Universal Link (iOS) -- https://contoh.com/produk/123
App Link (Android) -- https://contoh.com/produk/123
URL yang sama bisa dibuka di browser ATAU app
Lebih andal, direkomendasikan untuk produksi
Setup Custom URL Scheme #
<!-- android/app/src/main/AndroidManifest.xml -->
<activity ...>
<!-- Intent filter untuk custom scheme -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="contoh.com" />
</intent-filter>
</activity>
<!-- ios/Runner/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
Setup Universal Link / App Link (Direkomendasikan) #
// Buat file di server: https://contoh.com/.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.contoh.app",
"paths": ["/produk/*", "/pesanan/*", "/promo"]
}
]
}
}
// https://contoh.com/.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.contoh.app",
"sha256_cert_fingerprints": ["FINGERPRINT_SHA256_KAMU"]
}
}]
Integrasi Deep Link dengan GoRouter #
// lib/core/router/app_router.dart
import 'package:app_links/app_links.dart'; // package untuk handle deep link
import 'package:go_router/go_router.dart';
class AppRouter {
static final _appLinks = AppLinks();
static final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(path: '/', builder: (ctx, state) => const HomeScreen()),
GoRoute(
path: '/produk/:id',
builder: (ctx, state) {
final id = state.pathParameters['id']!;
return ProdukDetailScreen(produkId: id);
},
),
GoRoute(
path: '/pesanan/:id',
builder: (ctx, state) {
final id = state.pathParameters['id']!;
return PesananDetailScreen(pesananId: id);
},
),
GoRoute(path: '/promo', builder: (ctx, state) => const PromoScreen()),
],
);
// Inisialisasi listener deep link
static Future<void> initialize() async {
// Cek apakah app dibuka dari deep link (saat terminated)
final initialUri = await _appLinks.getInitialLink();
if (initialUri != null) {
debugPrint('[DeepLink] Initial: $initialUri');
router.go(initialUri.path);
}
// Dengarkan deep link saat app di foreground/background
_appLinks.uriLinkStream.listen((uri) {
debugPrint('[DeepLink] Incoming: $uri');
// Navigasi ke path yang sesuai
final path = uri.path;
if (path.isNotEmpty) {
router.go(path);
}
});
}
}
// Di main()
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await NotificationService.initialize();
await AppRouter.initialize();
runApp(const MyApp());
}
Struktur Pesan FCM dari Backend #
// Payload yang dikirim backend ke FCM
{
"to": "FCM_TOKEN_DEVICE",
"notification": {
"title": "Pesanan Dikonfirmasi! 🎉",
"body": "Pesanan #ORD-12345 sedang diproses"
},
"data": {
"type": "pesanan",
"id": "12345",
"click_action": "FLUTTER_NOTIFICATION_CLICK"
},
"android": {
"notification": {
"channel_id": "high_importance_channel",
"sound": "default",
"click_action": "FLUTTER_NOTIFICATION_CLICK"
}
},
"apns": {
"payload": {
"aps": {
"badge": 1,
"sound": "default"
}
}
}
}
Ringkasan #
- Notifikasi ditangani berbeda tergantung state app: foreground (tampilkan manual via local notification), background (FCM otomatis +
onMessageOpenedApp), terminated (getInitialMessage()). Ketiga skenario harus di-handle.@pragma('vm:entry-point')wajib pada background handler FCM agar tidak ter-tree-shake di build release.- Untuk menampilkan notifikasi saat app di foreground, FCM tidak melakukannya otomatis — gunakan
flutter_local_notificationsuntuk tampilkan secara manual.- Universal Links (iOS) dan App Links (Android) lebih andal dari custom URL scheme — URL yang sama bisa dibuka di browser atau langsung di app. Butuh file verifikasi di server (
apple-app-site-associationdanassetlinks.json).- Gunakan package
app_linksuntuk menangkap deep link di semua kondisi (initial, foreground, background) dengan API yang konsisten lintas platform.- Selalu bawa data payload di notifikasi (bukan hanya
notificationbody) agar saat pengguna tap, app tahu halaman mana yang harus dibuka.- Test skenario notifikasi di device fisik — emulator sering tidak merepresentasikan perilaku notifikasi yang akurat, terutama untuk state terminated.
← Sebelumnya: Background Tasks Berikutnya: Internationalization →