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 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>
// 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"]
  }
}]

// 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_notifications untuk 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-association dan assetlinks.json).
  • Gunakan package app_links untuk menangkap deep link di semua kondisi (initial, foreground, background) dengan API yang konsisten lintas platform.
  • Selalu bawa data payload di notifikasi (bukan hanya notification body) 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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact