diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 37fa88b..1fc0c3d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,6 +3,8 @@ PODS: - Flutter - app_settings (5.1.1): - Flutter + - connectivity_plus (0.0.1): + - Flutter - Firebase/CoreOnly (11.10.0): - FirebaseCore (~> 11.10.0) - Firebase/Messaging (11.10.0): @@ -102,6 +104,7 @@ PODS: DEPENDENCIES: - alarm (from `.symlinks/plugins/alarm/ios`) - app_settings (from `.symlinks/plugins/app_settings/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) @@ -134,6 +137,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/alarm/ios" app_settings: :path: ".symlinks/plugins/app_settings/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" firebase_core: :path: ".symlinks/plugins/firebase_core/ios" firebase_messaging: @@ -162,6 +167,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: alarm: 9ff6d2dae9bd69c4022622f7e0a1e9c1dd70064e app_settings: 5127ae0678de1dcc19f2293271c51d37c89428b2 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 firebase_core: 2d4534e7b489907dcede540c835b48981d890943 firebase_messaging: 75bc93a4df25faccad67f6662ae872ac9ae69b64 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c6777df..c06029c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A241E2BF2EAA2F1C00664284 /* warning_alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = A241E2BE2EAA2F1C00664284 /* warning_alarm.caf */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -64,6 +65,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A241E2BE2EAA2F1C00664284 /* warning_alarm.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = warning_alarm.caf; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -109,6 +111,7 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + A241E2BE2EAA2F1C00664284 /* warning_alarm.caf */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, @@ -256,6 +259,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 5EFD34A62DA7D89800351DB2 /* GoogleService-Info.plist in Resources */, + A241E2BF2EAA2F1C00664284 /* warning_alarm.caf in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c2851..f9d920d 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/ios/Runner/Sounds/warning_alarm.caf b/ios/Runner/Sounds/warning_alarm.caf new file mode 100644 index 0000000..25dd61e Binary files /dev/null and b/ios/Runner/Sounds/warning_alarm.caf differ diff --git a/ios/warning_alarm.caf b/ios/warning_alarm.caf new file mode 100644 index 0000000..25dd61e Binary files /dev/null and b/ios/warning_alarm.caf differ diff --git a/lib/feature/home/home_screen.dart b/lib/feature/home/home_screen.dart index c16868d..d90268f 100644 --- a/lib/feature/home/home_screen.dart +++ b/lib/feature/home/home_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:developer'; +import 'package:alarm/alarm.dart'; import 'package:flutter/material.dart'; import '../../product/shared/shared_loading_animation.dart'; @@ -27,7 +28,6 @@ class _HomeScreenState extends State { bool isFunctionCall = false; Timer? getAllDevicesTimer; - @override void initState() { super.initState(); @@ -35,14 +35,16 @@ class _HomeScreenState extends State { const duration = Duration(seconds: 10); WidgetsBinding.instance.addPostFrameCallback((_) { // Code ở đây chạy sau khi giao diện render xong - getAllDevicesTimer = - Timer.periodic(duration, (Timer t) => homeBloc.getOwnerAndJoinedDevices(context)); + getAllDevicesTimer = Timer.periodic( + duration, (Timer t) => homeBloc.getOwnerAndJoinedDevices(context)); // Ví dụ: gọi API, scroll tới vị trí nào đó, v.v. }); - } - + Future loadAlarms() async { + final alarms = await Alarm.getAlarms(); + log("Alarms: $alarms"); + } @override void dispose() { @@ -54,196 +56,229 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { return StreamBuilder?>( - stream: homeBloc.streamAliasDevices, - builder: (context, aliasDevicesSnapshot) { - if(aliasDevicesSnapshot.data == null){ - homeBloc.getOwnerAndJoinedDevices(context); - return const SharedLoadingAnimation(); - }else{ - homeBloc.getOwnerDeviceState(context, aliasDevicesSnapshot.data ?? []); - homeBloc.getDeviceStatusAliasMap(aliasDevicesSnapshot.data ?? []); - checkSettingDevice(aliasDevicesSnapshot.data ?? []); - return Scaffold( - body: Padding( - padding: context.paddingLow, - child: SingleChildScrollView( - child: Column( - children: [ - Row( - children: [ - Text( - appLocalization(context).notification, - style: context.titleMediumTextStyle, - ), - SizedBox(width: context.lowValue), - StreamBuilder( - stream: homeBloc.streamCountNotification, - builder: (context, countSnapshot) { - if(countSnapshot.data == null){ - homeBloc.getOwnerDeviceState(context, aliasDevicesSnapshot.data ?? []); - return const Text("0"); - } else{ - return Text( - "(${countSnapshot.data ?? 0})", - style: context.titleMediumTextStyle, - ); - } - }, - ) - ], - ), - SizedBox( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: StreamBuilder>>( - stream: homeBloc.streamOwnerDevicesStatus, - builder: (context, ownerDevicesStatusSnapshot) { - if(ownerDevicesStatusSnapshot.data == null){ - homeBloc.getOwnerDeviceState(context, aliasDevicesSnapshot.data ?? []); - return const SharedComponentLoadingAnimation(); - }else{ - return AnimatedSwitcher( - duration: context.lowDuration, - transitionBuilder: - (Widget child, Animation animation) { - final offsetAnimation = Tween( - begin: const Offset(0.0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: animation, - curve: Curves.easeInOut, - )); - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: offsetAnimation, - child: child, - ), - ); - }, - child: ownerDevicesStatusSnapshot.data?['state'] != null || - ownerDevicesStatusSnapshot.data?['battery'] != null - ? ConstrainedBox( - key: const ValueKey('data'), - constraints: BoxConstraints( - minWidth: - MediaQuery.of(context).size.width), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - if (ownerDevicesStatusSnapshot.data?['state'] != null) - ...ownerDevicesStatusSnapshot.data!['state']! - .map( - (item) => SizedBox( - width: context.dynamicWidth(0.95), - child: FutureBuilder( - future: warningCard( - context, apiServices, item), - builder: (context, - warningCardSnapshot) { - if (warningCardSnapshot - .hasData) { - return warningCardSnapshot - .data!; - } else { - return const SizedBox - .shrink(); - } - }, - ), + stream: homeBloc.streamAliasDevices, + builder: (context, aliasDevicesSnapshot) { + if (aliasDevicesSnapshot.data == null) { + homeBloc.getOwnerAndJoinedDevices(context); + return const SharedLoadingAnimation(); + } else { + homeBloc.getOwnerDeviceState( + context, aliasDevicesSnapshot.data ?? []); + homeBloc.getDeviceStatusAliasMap(aliasDevicesSnapshot.data ?? []); + checkSettingDevice(aliasDevicesSnapshot.data ?? []); + return Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () { + loadAlarms(); + }, + child: const Icon(Icons.alarm), + ), + body: Padding( + padding: context.paddingLow, + child: SingleChildScrollView( + child: Column( + children: [ + Row( + children: [ + Text( + appLocalization(context).notification, + style: context.titleMediumTextStyle, + ), + SizedBox(width: context.lowValue), + StreamBuilder( + stream: homeBloc.streamCountNotification, + builder: (context, countSnapshot) { + if (countSnapshot.data == null) { + homeBloc.getOwnerDeviceState( + context, aliasDevicesSnapshot.data ?? []); + return const Text("0"); + } else { + return Text( + "(${countSnapshot.data ?? 0})", + style: context.titleMediumTextStyle, + ); + } + }, + ) + ], + ), + SizedBox( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: + StreamBuilder>>( + stream: homeBloc.streamOwnerDevicesStatus, + builder: (context, ownerDevicesStatusSnapshot) { + if (ownerDevicesStatusSnapshot.data == null) { + homeBloc.getOwnerDeviceState( + context, aliasDevicesSnapshot.data ?? []); + return const SharedComponentLoadingAnimation(); + } else { + return AnimatedSwitcher( + duration: context.lowDuration, + transitionBuilder: (Widget child, + Animation animation) { + final offsetAnimation = Tween( + begin: const Offset(0.0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeInOut, + )); + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: offsetAnimation, + child: child, + ), + ); + }, + child: ownerDevicesStatusSnapshot + .data?['state'] != + null || + ownerDevicesStatusSnapshot + .data?['battery'] != + null + ? ConstrainedBox( + key: const ValueKey('data'), + constraints: BoxConstraints( + minWidth: MediaQuery.of(context) + .size + .width), + child: Row( + mainAxisAlignment: + MainAxisAlignment.start, + children: [ + if (ownerDevicesStatusSnapshot + .data?['state'] != + null) + ...ownerDevicesStatusSnapshot + .data!['state']! + .map( + (item) => SizedBox( + width: context + .dynamicWidth(0.95), + child: FutureBuilder< + Widget>( + future: warningCard( + context, + apiServices, + item), + builder: (context, + warningCardSnapshot) { + if (warningCardSnapshot + .hasData) { + return warningCardSnapshot + .data!; + } else { + return const SizedBox + .shrink(); + } + }, + ), + ), + ) + .toList(), + if (ownerDevicesStatusSnapshot + .data?['battery'] != + null) + ...ownerDevicesStatusSnapshot + .data!['battery']! + .map( + (batteryItem) => SizedBox( + width: context + .dynamicWidth(0.95), + child: FutureBuilder< + Widget>( + future: + notificationCard( + context, + "lowBattery", + appLocalization( + context) + .low_battery_message, + batteryItem, + ), + builder: (context, + warningCardSnapshot) { + if (warningCardSnapshot + .hasData) { + return warningCardSnapshot + .data!; + } else { + return const SizedBox + .shrink(); + } + }, + ), + ), + ) + .toList(), + ], ), ) - .toList(), - if (ownerDevicesStatusSnapshot.data?['battery'] != null) - ...ownerDevicesStatusSnapshot.data!['battery']! - .map( - (batteryItem) => SizedBox( - width: context.dynamicWidth(0.95), - child: FutureBuilder( - future: notificationCard( - context, - "lowBattery", - appLocalization(context) - .low_battery_message, - batteryItem, - ), - builder: (context, - warningCardSnapshot) { - if (warningCardSnapshot - .hasData) { - return warningCardSnapshot - .data!; - } else { - return const SizedBox - .shrink(); - } - }, + : Padding( + key: const ValueKey('no_data'), + padding: context.paddingMedium, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons + .check_circle_outline_rounded, + size: 40, + color: Colors.green, + ), + SizedBox( + width: context.lowValue), + Text( + appLocalization(context) + .notification_description, + maxLines: 2, + overflow: + TextOverflow.ellipsis, + softWrap: true, + textAlign: TextAlign.start, + ), + ], ), ), - ) - .toList(), - ], - ), - ) - : Padding( - key: const ValueKey('no_data'), - padding: context.paddingMedium, - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.check_circle_outline_rounded, - size: 40, - color: Colors.green, ), - SizedBox(width: context.lowValue), - Text( - appLocalization(context) - .notification_description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - softWrap: true, - textAlign: TextAlign.start, - ), - ], - ), - ), - ), - ); - } - }, + ); + } + }, + ), ), ), - ), - StreamBuilder>?>( - stream: homeBloc.streamAllDevicesAliasMap, - builder: (context, allDevicesAliasMapSnapshot) { - if(allDevicesAliasMapSnapshot.data == null){ - homeBloc.getDeviceStatusAliasMap(aliasDevicesSnapshot.data ?? []); - return const SharedComponentLoadingAnimation(); - }else{ - final data = allDevicesAliasMapSnapshot.data!; - return OverviewCard( - isOwner: true, - total: data['all']?.length ?? 0, - active: data['online']?.length ?? 0, - inactive: data['offline']?.length ?? 0, - warning: data['warn']?.length ?? 0, - unused: data['not-use']?.length ?? 0, - showUnused: false, - ); - } - }, - ), - ], + StreamBuilder>?>( + stream: homeBloc.streamAllDevicesAliasMap, + builder: (context, allDevicesAliasMapSnapshot) { + if (allDevicesAliasMapSnapshot.data == null) { + homeBloc.getDeviceStatusAliasMap( + aliasDevicesSnapshot.data ?? []); + return const SharedComponentLoadingAnimation(); + } else { + final data = allDevicesAliasMapSnapshot.data!; + return OverviewCard( + isOwner: true, + total: data['all']?.length ?? 0, + active: data['online']?.length ?? 0, + inactive: data['offline']?.length ?? 0, + warning: data['warn']?.length ?? 0, + unused: data['not-use']?.length ?? 0, + showUnused: false, + ); + } + }, + ), + ], + ), ), ), - ), - ); - } - } - ); + ); + } + }); } void checkSettingDevice(List devices) async { diff --git a/lib/feature/main/main_screen.dart b/lib/feature/main/main_screen.dart index ab28826..7bc8368 100644 --- a/lib/feature/main/main_screen.dart +++ b/lib/feature/main/main_screen.dart @@ -76,11 +76,11 @@ class _MainScreenState extends State with WidgetsBindingObserver { mainBloc.sinkThemeMode.add(isLight); checkAndRequestPermission(); NotificationServices.requestNotificationPermission(); - NotificationPermission.instance.checkNotificationPermission(context); - bool? notificationStatus = await NotificationPermission.instance.requestNotificationPermission(); - if(!notificationStatus!){ - showNoIconTopSnackBar(context, "Yêu cầu quyền thông báo không thành công", Colors.orange, Colors.white12); - } + // NotificationPermission.instance.checkNotificationPermission(context); + // bool? notificationStatus = await NotificationPermission.instance.requestNotificationPermission(); + // if(notificationStatus == false){ + // showNoIconTopSnackBar(context, "Yêu cầu quyền thông báo không thành công", Colors.orange, Colors.white12); + // } } @override diff --git a/lib/main.dart b/lib/main.dart index 0dfc2c6..871bcdf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,9 @@ - import 'package:alarm/alarm.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' show PersistentTabController; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart' + show PersistentTabController; import 'firebase_options.dart'; import 'product/lang/l10n/app_localizations.dart'; @@ -15,16 +15,17 @@ import 'bloc/main_bloc.dart'; import 'product/base/bloc/base_bloc.dart'; import 'product/constant/navigation/navigation_router.dart'; - - PersistentTabController controller = PersistentTabController(initialIndex: 0); - void main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform, name: "sfm-notification"); - FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + name: "sfm-notification"); + FirebaseMessaging.onBackgroundMessage( + NotificationServices.firebaseMessagingBackgroundHandler); await Alarm.init(); + await Alarm.stopAll(); runApp( BlocProvider( child: const MyApp(), @@ -33,7 +34,6 @@ void main() async { ); } - class MyApp extends StatefulWidget { const MyApp({super.key}); @@ -67,7 +67,7 @@ class _MyAppState extends State { _themeData = theme; mainBloc.sinkTheme.add(_themeData); } - + @override void initState() { super.initState(); @@ -92,19 +92,18 @@ class _MyAppState extends State { initialData: _locale, builder: (context, languageSnapshot) { return StreamBuilder( - stream: mainBloc.streamTheme, - initialData: _themeData, - builder: (context, themeSnapshot) { - return MaterialApp.router( - theme: themeSnapshot.data, - routerConfig: router, - debugShowCheckedModeBanner: false, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - locale: languageSnapshot.data, - ); - } - ); + stream: mainBloc.streamTheme, + initialData: _themeData, + builder: (context, themeSnapshot) { + return MaterialApp.router( + theme: themeSnapshot.data, + routerConfig: router, + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: languageSnapshot.data, + ); + }); }, ); } diff --git a/lib/product/services/alarm_services.dart b/lib/product/services/alarm_services.dart index 7ab11b0..e50fb6d 100644 --- a/lib/product/services/alarm_services.dart +++ b/lib/product/services/alarm_services.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:alarm/alarm.dart'; +@pragma('vm:entry-point') class AlarmServices { Future showAlarm(String title, String body) async { final DateTime now = DateTime.now(); diff --git a/lib/product/services/api_services.dart b/lib/product/services/api_services.dart index 3e3f871..4ea59b3 100644 --- a/lib/product/services/api_services.dart +++ b/lib/product/services/api_services.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; @@ -27,8 +29,6 @@ import '../constant/enums/local_keys_enums.dart'; import '../network/network_manager.dart'; class APIServices { - - Map headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', @@ -92,7 +92,7 @@ class APIServices { // try { // return await apiCall(); // } catch (e) { - + // AppLoggerUtils.error(e.toString()); // return Future.error(e); // } @@ -104,12 +104,33 @@ class APIServices { bool checkMounted = true, }) async { try { + // Quick connectivity check before attempting network calls. + final conn = await Connectivity().checkConnectivity(); + if (conn == ConnectivityResult.none) { + AppLoggerUtils.warning('No network connectivity'); + if (checkMounted && context.mounted) { + showErrorTopSnackBarCustom(context, "Không có kết nối mạng"); + } + return Future.error(const SocketException('No network connectivity')); + } + return await apiCall(); - } catch (e) { + } on SocketException catch (e, stackTrace) { + // Network-related errors (DNS, timeout, host lookup...) + AppLoggerUtils.warning('Network error when calling API: $e'); + if (checkMounted && context.mounted) { + AppLoggerUtils.error("Không có kết nối mạng"); + } + return Future.error(e); + } catch (e, stackTrace) { + // If widget was unmounted (e.g. background isolate), preserve previous behavior if (checkMounted && !context.mounted) { return Future.error('Widget not mounted'); } - showErrorTopSnackBarCustom(context, "Lỗi hệ thống"); + AppLoggerUtils.error("Lỗi hệ thống khi gọi API", e, stackTrace); + if (checkMounted && context.mounted) { + showErrorTopSnackBarCustom(context, "Lỗi hệ thống"); + } return Future.error(e); } } @@ -308,7 +329,6 @@ class APIServices { .getDataFromServer(APIPathConstants.PROVINCES_PATH), parser: (json) => Province.fromJsonDynamicList(json['items']), errorMessage: 'Lỗi khi GET /${APIPathConstants.PROVINCES_PATH}'); - } Future> getProvincesByName(String name) async { @@ -319,7 +339,6 @@ class APIServices { parser: (json) => Province.fromJsonDynamicList(json['items']), errorMessage: 'Lỗi khi GET /${APIPathConstants.PROVINCES_PATH}/$name', ); - } Future getProvinceByID(String provinceID) async { diff --git a/lib/product/services/notification_services.dart b/lib/product/services/notification_services.dart index 3797c2e..99d7ae8 100644 --- a/lib/product/services/notification_services.dart +++ b/lib/product/services/notification_services.dart @@ -4,20 +4,21 @@ import 'dart:math' as math; import 'package:alarm/alarm.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart' - hide NotificationSettings; + as firebase_messaging; import 'package:flutter/material.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../utils/app_logger_utils.dart'; import '../../firebase_options.dart'; -import '../../main.dart'; import 'alarm_services.dart'; +@pragma('vm:entry-point') class NotificationServices { static final FlutterLocalNotificationsPlugin _notificationsPlugin = FlutterLocalNotificationsPlugin(); - final FirebaseMessaging _messaging = FirebaseMessaging.instance; + final firebase_messaging.FirebaseMessaging _messaging = + firebase_messaging.FirebaseMessaging.instance; AlarmServices alarmServices = AlarmServices(); Future initialize() async { @@ -25,36 +26,51 @@ class NotificationServices { dev.log("NotificationService initialized"); } + @pragma('vm:entry-point') Future initializeLocalNotifications() async { try { - const AndroidInitializationSettings androidInitializationSettings = + const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); - const DarwinInitializationSettings iosInitializationSettings = - DarwinInitializationSettings(); - const InitializationSettings initializationSettings = - InitializationSettings( - android: androidInitializationSettings, - iOS: iosInitializationSettings, + final darwinInitSettings = DarwinInitializationSettings( + onDidReceiveLocalNotification: _onDidReceiveLocalNotification, + ); + final initSettings = InitializationSettings( + android: androidInitSettings, + iOS: darwinInitSettings, ); await _notificationsPlugin.initialize( - initializationSettings, - onDidReceiveNotificationResponse: (NotificationResponse response) { - dev.log( - "Người dùng click thông báo ở foreground với payload: ${response.payload}"); - handleMessage(response.payload, controller); - }, + initSettings, + onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse, ); + dev.log("Local notifications initialized"); } catch (e) { - dev.log("Error initializing local notifications: $e"); + AppLoggerUtils.error("Failed to initialize local notifications", e); + } + } + + Future _onDidReceiveLocalNotification( + int id, String? title, String? body, String? payload) async { + // Handle local notification tap when app is foreground (iOS) + dev.log("Local notification tapped (foreground): payload=$payload"); + } + + Future _onDidReceiveNotificationResponse( + NotificationResponse response) async { + // Handle local notification tap (both foreground & background) + final payload = response.payload; + dev.log("Notification tapped: payload=$payload"); + + if (payload != null && payload.isNotEmpty) { + final controller = PersistentTabController(initialIndex: 0); + handleMessage(payload, controller); } } void firebaseInit(BuildContext context) { - FirebaseMessaging.onMessage.listen((message) { - dev.log("Foreground message payload: ${message.toMap()}"); - _showNotification(message); + firebase_messaging.FirebaseMessaging.onMessage.listen((message) { + _handleForegroundMessage(message); }); } @@ -66,75 +82,156 @@ class NotificationServices { static Future requestNotificationPermission() async { try { - if (Platform.isAndroid) { - return await _notificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() - ?.requestNotificationsPermission(); - } else if (Platform.isIOS) { - return await _notificationsPlugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin>() - ?.requestPermissions(alert: true, sound: true, badge: true); - } - return null; + final messaging = firebase_messaging.FirebaseMessaging.instance; + final settings = await messaging.requestPermission( + alert: true, + announcement: false, + badge: true, + provisional: false, + sound: true, + ); + return settings.authorizationStatus == + firebase_messaging.AuthorizationStatus.authorized; } catch (e) { - dev.log("Error requesting notification permission: $e"); + AppLoggerUtils.error("Failed to request notification permission", e); return null; } } - Future _showNotification(RemoteMessage message) async { + /// Xử lý message khi app foreground + Future _handleForegroundMessage( + firebase_messaging.RemoteMessage message) async { try { - // Early validation of notification data - final title = message.notification?.title ?? "Title" as String?; - final body = message.notification?.body ?? "Body" as String?; final type = message.data['type'] as String? ?? 'normal'; + final title = + message.notification?.title ?? message.data['title'] ?? 'SFM'; + final body = message.notification?.body ?? message.data['body'] ?? ''; - if (title == null || body == null) { - dev.log('Skipping notification: missing title or body', - name: 'Notification'); - return; - } + dev.log('Foreground message: type=$type, title=$title, body=$body'); - // Handle smoke warning notifications if (type == 'smoke_warning') { - await alarmServices.showAlarm(title, body); - dev.log('Displayed smoke warning notification', name: 'Notification'); - return; + // Hiển thị alarm, không hiển thị notification + await _showAlarm(title, body); + } else { + // Hiển thị notification local + await _showForegroundNotification(title, body, type); } + } catch (e, stackTrace) { + AppLoggerUtils.error('Error handling foreground message', e, stackTrace); + } + } - // Create notification channel - final channelId = math.Random.secure().nextInt(1000000).toString(); - const channelName = 'High Importance Notification'; - const channelDescription = 'Channel description'; + /// Hiển thị alarm (smoke warning) + Future _showAlarm(String title, String body) async { + try { + await Alarm.init(); - // final androidChannel = AndroidNotificationChannel( - // channelId, - // channelName, - // importance: Importance.max, - // description: channelDescription, - // ); + final alarmSettings = AlarmSettings( + id: 42, + dateTime: DateTime.now(), + assetAudioPath: 'assets/sounds/warning_alarm.mp3', + loopAudio: true, + vibrate: true, + warningNotificationOnKill: Platform.isIOS, + androidFullScreenIntent: true, + volumeSettings: VolumeSettings.fade( + volume: 0.8, + fadeDuration: const Duration(seconds: 5), + volumeEnforced: true, + ), + notificationSettings: NotificationSettings( + title: title, + body: body, + stopButton: 'Dừng thông báo', + icon: "ic_launcher", + ), + ); - final androidPlugin = - _notificationsPlugin.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); + await Alarm.set(alarmSettings: alarmSettings); + dev.log('Alarm set successfully: $title'); + } catch (e, stackTrace) { + AppLoggerUtils.error('Failed to set alarm', e, stackTrace); + } + } - // Delete existing channel to prevent conflicts - await androidPlugin?.deleteNotificationChannel(channelId); - - // Configure notification details + /// Hiển thị notification local khi app foreground + Future _showForegroundNotification( + String title, String body, String type) async { + try { final androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, + 'sfm_channel_${math.Random.secure().nextInt(1000000)}', + 'SFM Notifications', + channelDescription: 'Notifications from SFM app', sound: getSound(type), importance: Importance.max, priority: Priority.high, ticker: 'ticker', ); - const iosDetails = DarwinNotificationDetails( + final iosDetails = DarwinNotificationDetails( + sound: _getSoundNameForIOS(type), + presentAlert: true, + presentBadge: true, + presentBanner: true, + presentSound: true, + ); + + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notificationsPlugin.show( + math.Random.secure().nextInt(1000000), + title, + body, + notificationDetails, + payload: type, + ); + + dev.log('Foreground notification shown: title=$title, type=$type'); + } catch (e, stackTrace) { + AppLoggerUtils.error( + 'Failed to show foreground notification', e, stackTrace); + } + } + + /// Hiển thị notification cho background/terminate + Future showBackgroundOrTerminateNotification( + firebase_messaging.RemoteMessage message) async { + try { + final type = message.data['type'] as String? ?? 'normal'; + final title = + message.notification?.title ?? message.data['title'] ?? 'SFM'; + final body = message.notification?.body ?? message.data['body'] ?? ''; + + dev.log('Background/terminate notification: type=$type, title=$title'); + + // Cho Android: nếu type = smoke_warning, hiển thị alarm + // Cho iOS: chỉ hiển thị notification, APNs payload sẽ phát sound natively + if (type == 'smoke_warning' && Platform.isAndroid) { + dev.log('→ Showing alarm for Android background'); + await _showAlarm(title, body); + return; + } + + // Hiển thị notification (cho cả iOS lẫn Android, nhưng iOS không có alarm) + final channelId = math.Random.secure().nextInt(1000000).toString(); + const channelName = 'SFM Notifications'; + const channelDescription = 'Notifications from SFM app'; + + final androidDetails = AndroidNotificationDetails( + channelId, + channelName, + channelDescription: channelDescription, + sound: getSound(type), + importance: Importance.max, + priority: Priority.high, + ticker: 'ticker', + ); + + final iosDetails = DarwinNotificationDetails( + sound: _getSoundNameForIOS(type), presentAlert: true, presentBadge: true, presentBanner: true, @@ -146,7 +243,6 @@ class NotificationServices { iOS: iosDetails, ); - // Show notification await _notificationsPlugin.show( int.parse(channelId), title, @@ -156,16 +252,10 @@ class NotificationServices { ); dev.log( - 'Displayed notification - title: $title, body: $body, type: $type', - name: 'Notification', - ); + 'Background/terminate notification shown: title=$title, type=$type'); } catch (e, stackTrace) { - dev.log( - 'Failed to show notification: $e', - name: 'Notification', - error: e, - stackTrace: stackTrace, - ); + AppLoggerUtils.error( + 'Failed to show background/terminate notification', e, stackTrace); } } @@ -179,153 +269,110 @@ class NotificationServices { } else if (type == "battery_warning") { return const RawResourceAndroidNotificationSound("new_alarm"); } else { - return const RawResourceAndroidNotificationSound("normal"); + return const RawResourceAndroidNotificationSound("warning_alarm"); } } + String _getSoundNameForIOS(String type) { + // iOS tìm file trong app bundle (không có đuôi) + if (type == "smoke_warning" || type == "battery_warning") { + return "warning_alarm"; // file: warning_alarm.caf trong bundle + } + return "default"; + } + void handleMessage(String? payload, PersistentTabController controller) { - dev.log("Handling notification tap with payload: $payload"); + AppLoggerUtils.info("Handling notification tap with payload: $payload"); controller.jumpToTab(1); + + if (payload == "smoke_warning") { + // TODO: Navigate to smoke warning screen nếu cần + // NavigationRouter.navigateToSmokeWarningScreen(); + } else if (payload == "battery_warning") { + // TODO: Navigate to battery warning screen nếu cần + // NavigationRouter.navigateToBatteryWarningScreen(); + } } Future setupInteractMessage(PersistentTabController controller) async { - // Khi app terminated - RemoteMessage? initialMessage = - await FirebaseMessaging.instance.getInitialMessage(); + // Khi app terminated, được mở bằng notification + firebase_messaging.RemoteMessage? initialMessage = + await firebase_messaging.FirebaseMessaging.instance.getInitialMessage(); if (initialMessage != null) { WidgetsBinding.instance.addPostFrameCallback((_) { - try { - handleMessage(initialMessage.data['type'], controller); - } catch (e, stack) { - dev.log("Error handling initial message: $e\n$stack"); - } + final type = initialMessage.data['type'] as String?; + handleMessage(type, controller); }); } - // Khi app ở background - FirebaseMessaging.onMessageOpenedApp.listen((message) { + + // Khi app background, user tap notification + firebase_messaging.FirebaseMessaging.onMessageOpenedApp.listen((message) { try { - handleMessage(message.data['type'], controller); - } catch (e, stack) { - dev.log("Error in onMessageOpenedApp: $e\n$stack"); + final type = message.data['type'] as String?; + handleMessage(type, controller); + } catch (e, stackTrace) { + AppLoggerUtils.error('Error in onMessageOpenedApp', e, stackTrace); } }); } - Future showBackgroundOrTerminateNotification( - RemoteMessage message) async { + @pragma('vm:entry-point') + static Future firebaseMessagingBackgroundHandler( + firebase_messaging.RemoteMessage message) async { try { - // Early validation of notification data - final title = message.data['title'] as String?; - final body = message.data['body'] as String?; - final type = message.data['type'] as String? ?? 'normal'; + // Log to device storage for debugging background isolate + dev.log('═══ BACKGROUND HANDLER STARTED ═══'); + dev.log('Message data: ${message.data}'); + dev.log('Notification: ${message.notification}'); - if (title == null || body == null) { - dev.log('Skipping notification: missing title or body', - name: 'Notification'); - return; + await Alarm.init(); + dev.log('✓ Alarm initialized'); + + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + name: "sfm-notification", + ); + dev.log('✓ Firebase initialized in background'); + } else { + dev.log('✓ Firebase already initialized'); } - // Create notification channel - final channelId = math.Random.secure().nextInt(1000000).toString(); - const channelName = 'High Importance Notification'; - const channelDescription = 'Channel description'; + final type = message.data['type'] as String? ?? 'normal'; + final title = message.notification?.title ?? + message.data['title'] ?? + 'Thông báo từ SmartFM'; + final body = message.notification?.body ?? + message.data['body'] ?? + 'Bạn có một thông báo mới'; - // Configure notification details - final androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - sound: getSound(type), - importance: Importance.max, - priority: Priority.high, - ticker: 'ticker', - ); + dev.log('Message type: $type'); + dev.log('Title: $title'); + dev.log('Body: $body'); - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentBanner: true, - presentSound: true, - ); + // Logic: Hiển thị local notification cho background/terminate + // iOS sẽ phát sound natively thông qua APNs payload (aps.sound) + // Android sẽ phát sound thông qua local notification + try { + final notificationService = NotificationServices(); + await notificationService.initialize(); + dev.log('✓ Notification service initialized'); - final notificationDetails = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); + await notificationService + .showBackgroundOrTerminateNotification(message); + dev.log('✓ Background/terminate notification shown'); + } catch (notifError, notifStackTrace) { + dev.log('✗ ERROR showing notification: $notifError'); + dev.log('Stack trace: $notifStackTrace'); + rethrow; + } - // Show notification - await _notificationsPlugin.show( - int.parse(channelId), - title, - body, - notificationDetails, - payload: type, - ); - - dev.log( - 'Displayed notification - title: $title, body: $body, type: $type', - name: 'Notification', - ); + dev.log('═══ BACKGROUND HANDLER COMPLETED ═══'); } catch (e, stackTrace) { - dev.log( - 'Failed to show notification: $e', - name: 'Notification', - error: e, - stackTrace: stackTrace, - ); + dev.log('✗ BACKGROUND HANDLER FAILED'); + dev.log('Error: $e'); + dev.log('Stack trace: $stackTrace'); } } } - -@pragma('vm:entry-point') -Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { - try { - AppLoggerUtils.warning("Background handler started: ${message.data}"); - await Alarm.init(); - if (Firebase.apps.isEmpty) { - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - name: "sfm-notification"); - AppLoggerUtils.warning( - "Firebase đã được khởi tạo trong background handler"); - } else { - AppLoggerUtils.warning("Firebase đã được khởi tạo trước đó"); - } - AppLoggerUtils.warning(message.toString()); - AppLoggerUtils.warning(message.data.toString()); - AppLoggerUtils.warning(message.data["notification"].toString()); - String? title = message.data['title']; - String? body = message.data['body']; - String type = message.data['type'] ?? "normal"; - final notificationService = NotificationServices(); - await notificationService.initialize(); - final alarmSettings = AlarmSettings( - id: 42, - dateTime: DateTime.now(), - assetAudioPath: 'assets/sounds/warning_alarm.mp3', - loopAudio: true, - vibrate: true, - warningNotificationOnKill: Platform.isIOS, - androidFullScreenIntent: true, - volumeSettings: VolumeSettings.fade( - volume: 0.8, - fadeDuration: const Duration(seconds: 5), - volumeEnforced: true, - ), - notificationSettings: NotificationSettings( - title: title ?? "SFM", - body: body ?? "", - stopButton: 'Dừng thông báo', - icon: "ic_launcher", - ), - ); - if (type == "smoke_warning") { - await Alarm.set(alarmSettings: alarmSettings); - } else { - await notificationService.showBackgroundOrTerminateNotification(message); - } - } catch (e) { - AppLoggerUtils.warning("Error in background handler: $e"); - } -} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 50a4ed1..cd2ae63 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import app_settings +import connectivity_plus import firebase_core import firebase_messaging import flutter_local_notifications @@ -16,6 +17,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) diff --git a/pubspec.lock b/pubspec.lock index d8cfd73..5f40980 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" crypto: dependency: transitive description: @@ -629,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6b1acec..8e7d316 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: lottie: ^3.3.1 logger: ^2.5.0 alarm: ^5.1.4 + connectivity_plus: ^7.0.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index e7f561c..5390db7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -13,6 +14,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); GeolocatorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 5feb846..ac3e234 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus firebase_core geolocator_windows maps_launcher