diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a9884f6..4928c43 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") } android { @@ -11,6 +12,7 @@ android { ndkVersion = "27.0.12077973" compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } @@ -24,7 +26,7 @@ android { applicationId = "com.apskel.enaklo_owner" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 21 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName @@ -42,3 +44,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7a2c596..80f9dd1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,10 @@ + + + + + android:resource="@drawable/ic_notification" /> + + + + + + + + + + + #FF6B35 \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ab39a10..0bb8e82 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,6 +20,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.3" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.google.gms.google-services") version "4.4.2" apply false } include(":app") diff --git a/assets/images/ic_notification.png b/assets/images/ic_notification.png new file mode 100644 index 0000000..63967d0 Binary files /dev/null and b/assets/images/ic_notification.png differ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..a725045 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate { @@ -7,7 +8,28 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // Set notification delegate so notifications show in foreground & background + UNUserNotificationCenter.current().delegate = self + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + // Called when a notification is delivered while app is in foreground + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .badge, .sound]) + } + + // Called when user taps a notification (foreground or background) + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + completionHandler() + } } diff --git a/lib/common/constant/app_constant.dart b/lib/common/constant/app_constant.dart index e421be3..26db70f 100644 --- a/lib/common/constant/app_constant.dart +++ b/lib/common/constant/app_constant.dart @@ -1,3 +1,3 @@ class AppConstant { - static const String appName = "Apskel Owner"; + static const String appName = "Enaklo Owner"; } diff --git a/lib/common/theme/app_color.dart b/lib/common/theme/app_color.dart index ab8f9b2..5485daa 100644 --- a/lib/common/theme/app_color.dart +++ b/lib/common/theme/app_color.dart @@ -2,9 +2,9 @@ part of 'theme.dart'; class AppColor { // Primary Colors - static const Color primary = Color(0xFF36175e); - static const Color primaryLight = Color(0xFF5a2d85); - static const Color primaryDark = Color(0xFF1e0d35); + static const Color primary = Color.fromARGB(255, 196, 2, 2); // #d90000 + static const Color primaryLight = Color(0xFFFF4D4D); // merah terang + static const Color primaryDark = Color(0xFF990000); // merah gelap // Secondary Colors static const Color secondary = Color(0xFF4CAF50); @@ -41,10 +41,9 @@ class AppColor { // Gradient Colors static const List primaryGradient = [ - Color(0xFF36175e), - Color(0xFF5a2d85), + Color(0xFFD90000), // primary + Color(0xFF990000), // dark red ]; - static const List successGradient = [ Color(0xFF4CAF50), Color(0xFF81C784), diff --git a/lib/common/utils/fcm_service.dart b/lib/common/utils/fcm_service.dart index 2a1a17e..de2dff5 100644 --- a/lib/common/utils/fcm_service.dart +++ b/lib/common/utils/fcm_service.dart @@ -1,15 +1,54 @@ import 'dart:convert'; import 'dart:io'; +import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:injectable/injectable.dart'; /// Background message handler — must be a top-level function. +/// Firebase must be initialized here for background isolate. @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(); debugPrint('[FCM] Background message: ${message.messageId}'); + // Show local notification for data-only messages in background + await _showBackgroundNotification(message); +} + +/// Standalone local notifications plugin for background isolate use. +final _backgroundLocalNotifications = FlutterLocalNotificationsPlugin(); + +Future _showBackgroundNotification(RemoteMessage message) async { + const androidInit = AndroidInitializationSettings('@drawable/ic_notification'); + await _backgroundLocalNotifications.initialize( + const InitializationSettings(android: androidInit), + ); + + final notification = message.notification; + // Only show manually for data-only messages (FCM auto-shows notification messages) + if (notification != null) return; + + final title = message.data['title'] as String?; + final body = message.data['body'] as String?; + if (title == null && body == null) return; + + await _backgroundLocalNotifications.show( + message.hashCode, + title, + body, + const NotificationDetails( + android: AndroidNotificationDetails( + 'high_importance_channel', + 'High Importance Notifications', + importance: Importance.high, + priority: Priority.high, + icon: '@drawable/ic_notification', + ), + ), + payload: jsonEncode(message.data), + ); } @lazySingleton @@ -23,7 +62,10 @@ class FcmService { 'high_importance_channel', 'High Importance Notifications', description: 'This channel is used for important notifications.', - importance: Importance.high, + importance: Importance.max, // max agar banner (heads-up) muncul + playSound: true, + enableVibration: true, + enableLights: true, ); /// Call this once during app startup (after Firebase.initializeApp). @@ -124,11 +166,19 @@ class FcmService { _androidChannel.id, _androidChannel.name, channelDescription: _androidChannel.description, - icon: '@mipmap/launcher_icon', - importance: Importance.high, + icon: '@drawable/ic_notification', + importance: Importance.max, priority: Priority.high, + playSound: true, + enableVibration: true, + // Heads-up notification (banner) + fullScreenIntent: false, + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, ), - iOS: const DarwinNotificationDetails(), ), payload: jsonEncode(message.data), ); diff --git a/lib/presentation/pages/home/widgets/feature.dart b/lib/presentation/pages/home/widgets/feature.dart index 8295f6b..2a05816 100644 --- a/lib/presentation/pages/home/widgets/feature.dart +++ b/lib/presentation/pages/home/widgets/feature.dart @@ -62,36 +62,36 @@ class HomeFeature extends StatelessWidget { ), ], ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - HomeFeatureTile( - title: context.lang.form, - color: const Color(0xFFE91E63), - icon: LineIcons.fileAlt, - onTap: () => context.router.push(DailyTasksFormRoute()), - ), - HomeFeatureTile( - title: context.lang.schedule, - color: const Color(0xFF9C27B0), - icon: LineIcons.calendar, - onTap: () => context.router.push(ScheduleRoute()), - ), - HomeFeatureTile( - title: context.lang.inventory, - color: const Color(0xFF00BCD4), - icon: LineIcons.archive, - onTap: () => context.router.push(InventoryRoute()), - ), - HomeFeatureTile( - title: context.lang.customer, - color: const Color(0xFFFF5722), - icon: LineIcons.userPlus, - onTap: () => context.router.push(CustomerRoute()), - ), - ], - ), + // Row( + // mainAxisAlignment: MainAxisAlignment.spaceAround, + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // HomeFeatureTile( + // title: context.lang.form, + // color: const Color(0xFFE91E63), + // icon: LineIcons.fileAlt, + // onTap: () => context.router.push(DailyTasksFormRoute()), + // ), + // HomeFeatureTile( + // title: context.lang.schedule, + // color: const Color(0xFF9C27B0), + // icon: LineIcons.calendar, + // onTap: () => context.router.push(ScheduleRoute()), + // ), + // HomeFeatureTile( + // title: context.lang.inventory, + // color: const Color(0xFF00BCD4), + // icon: LineIcons.archive, + // onTap: () => context.router.push(InventoryRoute()), + // ), + // HomeFeatureTile( + // title: context.lang.customer, + // color: const Color(0xFFFF5722), + // icon: LineIcons.userPlus, + // onTap: () => context.router.push(CustomerRoute()), + // ), + // ], + // ), ], ), ); diff --git a/lib/presentation/pages/profile/profile_page.dart b/lib/presentation/pages/profile/profile_page.dart index 48cbc92..1607221 100644 --- a/lib/presentation/pages/profile/profile_page.dart +++ b/lib/presentation/pages/profile/profile_page.dart @@ -1,5 +1,7 @@ import 'package:auto_route/auto_route.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:line_icons/line_icons.dart'; import 'package:loader_overlay/loader_overlay.dart'; @@ -8,6 +10,7 @@ import '../../../application/auth/auth_bloc.dart'; import '../../../application/auth/logout_form/logout_form_bloc.dart'; import '../../../common/extension/extension.dart'; import '../../../common/theme/theme.dart'; +import '../../../common/utils/fcm_service.dart'; import '../../../injection.dart'; import '../../components/button/button.dart'; import '../../components/spacer/spacer.dart'; @@ -61,7 +64,7 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper { backgroundColor: AppColor.primary, elevation: 0, pinned: true, - expandedHeight: 264.0, + expandedHeight: 270.0, flexibleSpace: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { @@ -70,7 +73,7 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper { final double collapsedHeight = MediaQuery.of(context).padding.top + kToolbarHeight; - final double expandedHeight = 264.0; + final double expandedHeight = 270.0; final double shrinkRatio = ((expandedHeight - top) / (expandedHeight - collapsedHeight)) @@ -125,6 +128,9 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper { const SpaceHeight(12), ProfileDangerZone(), const SpaceHeight(30), + // Debug only: FCM Token + if (kDebugMode) const _FcmTokenDebugWidget(), + const SpaceHeight(30), ], ), ), @@ -140,3 +146,84 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper { Widget wrappedRoute(BuildContext context) => BlocProvider(create: (_) => getIt(), child: this); } + +class _FcmTokenDebugWidget extends StatefulWidget { + const _FcmTokenDebugWidget(); + + @override + State<_FcmTokenDebugWidget> createState() => _FcmTokenDebugWidgetState(); +} + +class _FcmTokenDebugWidgetState extends State<_FcmTokenDebugWidget> { + String? _token; + + @override + void initState() { + super.initState(); + _loadToken(); + } + + Future _loadToken() async { + final token = await getIt().getToken(); + if (mounted) setState(() => _token = token); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColor.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColor.black.withOpacity(0.1)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.bug_report, size: 14), + const SizedBox(width: 4), + Text( + 'FCM Token (debug only)', + style: AppStyle.sm.copyWith(fontWeight: FontWeight.w700), + ), + const Spacer(), + GestureDetector( + onTap: () async { + if (_token == null) return; + await Clipboard.setData(ClipboardData(text: _token!)); + debugPrint('[FCM] Token copied: $_token'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('FCM token copied!'), + duration: Duration(seconds: 2), + ), + ); + } + }, + child: Text( + 'Copy', + style: AppStyle.sm.copyWith( + color: AppColor.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + _token ?? 'Loading...', + style: AppStyle.xs.copyWith(color: AppColor.black.withOpacity(0.6)), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index ad8712e..a114888 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,8 +70,8 @@ flutter: uses-material-design: true assets: - assets/images/ - - assets/icons/ - - assets/json/ + # - assets/icons/ + # - assets/json/ fonts: - family: Quicksand