diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 10ce40f..da646ac 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,6 +14,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -45,6 +46,8 @@ flutter { } dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + // Import the Firebase BoM implementation(platform("com.google.firebase:firebase-bom:34.4.0")) diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification.png b/android/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000..00e1cd6 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification.png b/android/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..db5dc71 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..a6dd8de Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000..ca65742 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000..bba3242 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/lib/common/di/di_firebase.dart b/lib/common/di/di_firebase.dart new file mode 100644 index 0000000..fc9f3a3 --- /dev/null +++ b/lib/common/di/di_firebase.dart @@ -0,0 +1,13 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:injectable/injectable.dart'; + +@module +abstract class FirebaseDi { + @lazySingleton + FirebaseMessaging get messaging => FirebaseMessaging.instance; + + @lazySingleton + FlutterLocalNotificationsPlugin get localNotifications => + FlutterLocalNotificationsPlugin(); +} diff --git a/lib/common/service/fcm_example_usage.dart b/lib/common/service/fcm_example_usage.dart new file mode 100644 index 0000000..5267d04 --- /dev/null +++ b/lib/common/service/fcm_example_usage.dart @@ -0,0 +1,87 @@ +// CONTOH PENGGUNAAN FCM SERVICE +// File ini hanya untuk referensi, tidak perlu diimport ke app + +import 'package:apskel_pos_flutter_v2/common/service/fcm_service.dart'; +import 'package:apskel_pos_flutter_v2/injection.dart'; + +/// Contoh 1: Ambil FCM token +Future exampleGetToken() async { + final fcmService = getIt(); + final token = await fcmService.getToken(); + print('FCM Token: $token'); + + // Kirim token ke server untuk push notification + // await apiClient.sendTokenToServer(token); +} + +/// Contoh 2: Subscribe ke topic +Future exampleSubscribeToTopic() async { + final fcmService = getIt(); + + // Subscribe ke topic "all_users" + await fcmService.subscribeToTopic('all_users'); + + // Subscribe ke topic berdasarkan outlet_id + await fcmService.subscribeToTopic('outlet_123'); +} + +/// Contoh 3: Unsubscribe dari topic +Future exampleUnsubscribeFromTopic() async { + final fcmService = getIt(); + + await fcmService.unsubscribeFromTopic('outlet_123'); +} + +/// Contoh 4: Gunakan di BLoC +/// +/// class NotificationBloc extends Bloc { +/// final FcmService _fcmService; +/// +/// NotificationBloc(this._fcmService) : super(NotificationInitial()) { +/// on(_onGetFcmToken); +/// on(_onSubscribeToTopic); +/// } +/// +/// Future _onGetFcmToken( +/// GetFcmToken event, +/// Emitter emit, +/// ) async { +/// final token = await _fcmService.getToken(); +/// emit(NotificationTokenLoaded(token)); +/// } +/// +/// Future _onSubscribeToTopic( +/// SubscribeToTopic event, +/// Emitter emit, +/// ) async { +/// await _fcmService.subscribeToTopic(event.topic); +/// emit(NotificationTopicSubscribed(event.topic)); +/// } +/// } + +/// Contoh 5: Gunakan di Repository +/// +/// @LazySingleton(as: IAuthRepository) +/// class AuthRepository implements IAuthRepository { +/// final FcmService _fcmService; +/// final ApiClient _apiClient; +/// +/// AuthRepository(this._fcmService, this._apiClient); +/// +/// @override +/// Future> login(String email, String password) async { +/// try { +/// final response = await _apiClient.login(email, password); +/// +/// // Kirim FCM token ke server setelah login berhasil +/// final fcmToken = await _fcmService.getToken(); +/// if (fcmToken != null) { +/// await _apiClient.updateFcmToken(fcmToken); +/// } +/// +/// return Right(response); +/// } catch (e) { +/// return Left(ServerFailure(e.toString())); +/// } +/// } +/// } diff --git a/lib/common/service/fcm_service.dart b/lib/common/service/fcm_service.dart new file mode 100644 index 0000000..cc1683d --- /dev/null +++ b/lib/common/service/fcm_service.dart @@ -0,0 +1,191 @@ +import 'dart:developer'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:injectable/injectable.dart'; + +/// Background message handler — harus top-level function +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + log('[FCM] Background message: ${message.messageId}'); +} + +@lazySingleton +class FcmService { + FcmService(this._messaging, this._localNotifications); + + final FirebaseMessaging _messaging; + final FlutterLocalNotificationsPlugin _localNotifications; + + /// Inisialisasi FCM: minta permission, set handler, ambil token + Future initialize() async { + // Setup local notifications + await _setupLocalNotifications(); + + // Register background handler + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // Minta permission notifikasi (iOS & Android 13+) + final settings = await _messaging.requestPermission( + alert: true, + badge: true, + sound: true, + provisional: false, + ); + + log('[FCM] Permission status: ${settings.authorizationStatus}'); + + if (settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional) { + await _setupTokenHandling(); + _setupForegroundHandler(); + _setupOpenedAppHandler(); + } + } + + Future _setupLocalNotifications() async { + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + + const initSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + await _localNotifications.initialize( + initSettings, + onDidReceiveNotificationResponse: _onNotificationTapped, + ); + + // Android notification channel + const channel = AndroidNotificationChannel( + 'high_importance_channel', + 'High Importance Notifications', + description: 'This channel is used for important notifications.', + importance: Importance.high, + ); + + await _localNotifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(channel); + } + + void _onNotificationTapped(NotificationResponse response) { + log('[FCM] Notification tapped: ${response.payload}'); + // TODO: handle navigation berdasarkan payload + } + + Future _setupTokenHandling() async { + final token = await getToken(); + log('========================================'); + log('[FCM] TOKEN: $token'); + log('========================================'); + // TODO: kirim token ke server jika diperlukan + + _messaging.onTokenRefresh.listen((newToken) { + log('========================================'); + log('[FCM] TOKEN REFRESHED: $newToken'); + log('========================================'); + // TODO: kirim token baru ke server jika diperlukan + }); + } + + void _setupForegroundHandler() { + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + log('[FCM] Foreground message: ${message.messageId}'); + log('[FCM] Title: ${message.notification?.title}'); + log('[FCM] Body: ${message.notification?.body}'); + log('[FCM] Data: ${message.data}'); + + // Tampilkan notifikasi lokal saat app di foreground + final notification = message.notification; + if (notification != null) { + _showLocalNotification( + id: message.hashCode, + title: notification.title ?? '', + body: notification.body ?? '', + payload: message.data.toString(), + ); + } + }); + } + + void _setupOpenedAppHandler() { + // App dibuka dari notifikasi saat terminated + _messaging.getInitialMessage().then((RemoteMessage? message) { + if (message != null) { + log('[FCM] App opened from terminated via: ${message.messageId}'); + _handleMessageNavigation(message); + } + }); + + // App dibuka dari notifikasi saat background + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { + log('[FCM] App opened from background via: ${message.messageId}'); + _handleMessageNavigation(message); + }); + } + + void _handleMessageNavigation(RemoteMessage message) { + // TODO: tambahkan logika navigasi berdasarkan message.data + log('[FCM] Handle navigation for: ${message.data}'); + } + + Future _showLocalNotification({ + required int id, + required String title, + required String body, + String? payload, + }) async { + const androidDetails = AndroidNotificationDetails( + 'high_importance_channel', + 'High Importance Notifications', + channelDescription: 'This channel is used for important notifications.', + importance: Importance.high, + priority: Priority.high, + showWhen: true, + icon: 'ic_notification', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _localNotifications.show(id, title, body, details, payload: payload); + log('[FCM] Local notification shown: $title'); + } + + /// Ambil FCM token perangkat + Future getToken() async { + try { + return await _messaging.getToken(); + } catch (e) { + log('[FCM] Failed to get token: $e'); + return null; + } + } + + /// Subscribe ke topic tertentu + Future subscribeToTopic(String topic) async { + await _messaging.subscribeToTopic(topic); + log('[FCM] Subscribed to topic: $topic'); + } + + /// Unsubscribe dari topic tertentu + Future unsubscribeFromTopic(String topic) async { + await _messaging.unsubscribeFromTopic(topic); + log('[FCM] Unsubscribed from topic: $topic'); + } +} diff --git a/lib/injection.config.dart b/lib/injection.config.dart index c551594..ac50a33 100644 --- a/lib/injection.config.dart +++ b/lib/injection.config.dart @@ -80,10 +80,12 @@ import 'package:apskel_pos_flutter_v2/common/di/di_auto_route.dart' as _i729; import 'package:apskel_pos_flutter_v2/common/di/di_connectivity.dart' as _i807; import 'package:apskel_pos_flutter_v2/common/di/di_database.dart' as _i209; import 'package:apskel_pos_flutter_v2/common/di/di_dio.dart' as _i86; +import 'package:apskel_pos_flutter_v2/common/di/di_firebase.dart' as _i857; import 'package:apskel_pos_flutter_v2/common/di/di_shared_preferences.dart' as _i135; import 'package:apskel_pos_flutter_v2/common/network/network_client.dart' as _i171; +import 'package:apskel_pos_flutter_v2/common/service/fcm_service.dart' as _i312; import 'package:apskel_pos_flutter_v2/domain/analytic/analytic.dart' as _i346; import 'package:apskel_pos_flutter_v2/domain/auth/auth.dart' as _i776; import 'package:apskel_pos_flutter_v2/domain/category/category.dart' as _i502; @@ -148,6 +150,9 @@ import 'package:apskel_pos_flutter_v2/presentation/router/app_router.dart' as _i800; import 'package:connectivity_plus/connectivity_plus.dart' as _i895; import 'package:dio/dio.dart' as _i361; +import 'package:firebase_messaging/firebase_messaging.dart' as _i892; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' + as _i163; import 'package:get_it/get_it.dart' as _i174; import 'package:injectable/injectable.dart' as _i526; import 'package:shared_preferences/shared_preferences.dart' as _i460; @@ -167,6 +172,7 @@ extension GetItInjectableX on _i174.GetIt { final autoRouteDi = _$AutoRouteDi(); final connectivityDi = _$ConnectivityDi(); final dioDi = _$DioDi(); + final firebaseDi = _$FirebaseDi(); gh.factory<_i13.CheckoutFormBloc>(() => _i13.CheckoutFormBloc()); gh.factory<_i96.PrinterBloc>(() => _i96.PrinterBloc()); gh.factory<_i257.ReportBloc>(() => _i257.ReportBloc()); @@ -179,6 +185,10 @@ extension GetItInjectableX on _i174.GetIt { gh.lazySingleton<_i800.AppRouter>(() => autoRouteDi.appRouter); gh.lazySingleton<_i895.Connectivity>(() => connectivityDi.connectivity); gh.lazySingleton<_i361.Dio>(() => dioDi.dio); + gh.lazySingleton<_i892.FirebaseMessaging>(() => firebaseDi.messaging); + gh.lazySingleton<_i163.FlutterLocalNotificationsPlugin>( + () => firebaseDi.localNotifications, + ); gh.lazySingleton<_i171.NetworkClient>( () => _i171.NetworkClient(gh<_i895.Connectivity>()), ); @@ -192,6 +202,12 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i464.ProductLocalDataProvider>( () => _i464.ProductLocalDataProvider(gh<_i487.DatabaseHelper>()), ); + gh.lazySingleton<_i312.FcmService>( + () => _i312.FcmService( + gh<_i892.FirebaseMessaging>(), + gh<_i163.FlutterLocalNotificationsPlugin>(), + ), + ); gh.factory<_i204.AuthLocalDataProvider>( () => _i204.AuthLocalDataProvider(gh<_i460.SharedPreferences>()), ); @@ -397,3 +413,5 @@ class _$AutoRouteDi extends _i729.AutoRouteDi {} class _$ConnectivityDi extends _i807.ConnectivityDi {} class _$DioDi extends _i86.DioDi {} + +class _$FirebaseDi extends _i857.FirebaseDi {} diff --git a/lib/main.dart b/lib/main.dart index 0b9f9fd..5344ce9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:injectable/injectable.dart'; +import 'common/service/fcm_service.dart'; import 'injection.dart'; import 'presentation/app_widget.dart'; @@ -47,10 +48,12 @@ void main() async { kReleaseMode ? Environment.prod : Environment.dev, ); + // Inisialisasi FCM setelah DI siap + await getIt().initialize(); + runApp(const AppWidget()); }, (error, stack) { - // ✅ Ini udah bener FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); }, ); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 13d89ca..0584489 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,8 @@ import Foundation import connectivity_plus import firebase_core import firebase_crashlytics +import firebase_messaging +import flutter_local_notifications import path_provider_foundation import shared_preferences_foundation import sqflite_darwin @@ -16,6 +18,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/pubspec.lock b/pubspec.lock index f6cad54..1cc3eea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -441,6 +441,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.8.14" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "5021279acd1cb5ccaceaa388e616e82cc4a2e4d862f02637df0e8ab766e6900a" + url: "https://pub.dev" + source: hosted + version: "16.0.3" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: f3a16c51f02055ace2a7c16ccb341c1f1b36b67c13270a48bcef68c1d970bbe8 + url: "https://pub.dev" + source: hosted + version: "4.7.3" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "3eb9a1382caeb95b370f21e36d4a460496af777c9c2ef5df9b90d4803982c069" + url: "https://pub.dev" + source: hosted + version: "4.0.3" fixnum: dependency: transitive description: @@ -526,6 +550,30 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_spinkit: dependency: "direct main" description: @@ -1269,6 +1317,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.5" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2b0d43f..4dc4db1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,8 @@ dependencies: shared_preferences: ^2.5.3 firebase_core: ^4.2.0 firebase_crashlytics: ^5.0.3 + firebase_messaging: ^16.0.3 + flutter_local_notifications: ^18.0.1 another_flushbar: ^1.12.32 flutter_spinkit: ^5.2.2 bloc: ^9.1.0 diff --git a/tool/generate_notification_icon.dart b/tool/generate_notification_icon.dart new file mode 100644 index 0000000..7ff0cac --- /dev/null +++ b/tool/generate_notification_icon.dart @@ -0,0 +1,47 @@ +import 'dart:io'; +import 'package:image/image.dart' as img; + +/// Jalankan dengan: dart run tool/generate_notification_icon.dart +void main() async { + final inputPath = 'assets/images/logo_white.png'; + final outputBase = 'android/app/src/main/res'; + + final sizes = { + 'drawable-mdpi': 24, + 'drawable-hdpi': 36, + 'drawable-xhdpi': 48, + 'drawable-xxhdpi': 72, + 'drawable-xxxhdpi': 96, + }; + + final inputFile = File(inputPath); + if (!inputFile.existsSync()) { + print('ERROR: File tidak ditemukan: $inputPath'); + exit(1); + } + + final originalBytes = inputFile.readAsBytesSync(); + final original = img.decodeImage(originalBytes); + if (original == null) { + print('ERROR: Gagal decode image'); + exit(1); + } + + print('Source: $inputPath (${original.width}x${original.height})'); + + for (final entry in sizes.entries) { + final folder = '$outputBase/${entry.key}'; + final outputPath = '$folder/ic_notification.png'; + + Directory(folder).createSync(recursive: true); + + final size = entry.value; + final resized = img.copyResize(original, width: size, height: size); + final pngBytes = img.encodePng(resized); + + File(outputPath).writeAsBytesSync(pngBytes); + print('Generated: $outputPath (${size}x${size})'); + } + + print('\nDone! Gunakan icon: "ic_notification" di fcm_service.dart'); +}