From d4dd475e0b9b000333c8d9c7f381ccc2d5d643b6 Mon Sep 17 00:00:00 2001 From: faizatflutter Date: Wed, 31 Dec 2025 10:33:00 +0300 Subject: [PATCH] Added Graph --- lib/core/cache_consts.dart | 1 + lib/core/common_models/data_points.dart | 24 +- lib/core/dependencies.dart | 60 +- lib/core/location_util.dart | 49 +- lib/core/post_params_model.dart | 27 +- lib/core/utils/date_util.dart | 60 +- lib/core/utils/local_notifications.dart | 191 ----- lib/core/utils/push_notification_handler.dart | 25 +- lib/core/utils/utils.dart | 5 +- lib/extensions/string_extensions.dart | 5 +- .../water_monitor/water_monitor_repo.dart | 1 - .../water_monitor_view_model.dart | 319 +++++-- lib/main.dart | 9 +- .../water_consumption_screen.dart | 810 +++++++++--------- .../water_monitor_settings_screen.dart | 25 +- .../widgets/hydration_tips_widget.dart | 62 ++ .../widgets/water_action_buttons_widget.dart | 176 ++++ .../widgets/water_bottle_widget.dart | 112 +++ .../water_consumption_progress_widget.dart | 10 +- .../widgets/water_intake_summary_widget.dart | 116 +++ lib/routes/app_routes.dart | 3 - lib/services/notification_service.dart | 373 ++++++++ lib/splashPage.dart | 29 +- lib/theme/colors.dart | 4 + lib/widgets/chip/app_custom_chip_widget.dart | 10 +- lib/widgets/graph/custom_graph.dart | 129 +-- pubspec.yaml | 1 + 27 files changed, 1711 insertions(+), 925 deletions(-) delete mode 100644 lib/core/utils/local_notifications.dart create mode 100644 lib/presentation/water_monitor/widgets/hydration_tips_widget.dart create mode 100644 lib/presentation/water_monitor/widgets/water_action_buttons_widget.dart create mode 100644 lib/presentation/water_monitor/widgets/water_bottle_widget.dart create mode 100644 lib/presentation/water_monitor/widgets/water_intake_summary_widget.dart create mode 100644 lib/services/notification_service.dart diff --git a/lib/core/cache_consts.dart b/lib/core/cache_consts.dart index bcbb185..8deb8bd 100644 --- a/lib/core/cache_consts.dart +++ b/lib/core/cache_consts.dart @@ -63,6 +63,7 @@ class CacheConst { static const String pharmacyAutorzieToken = 'PHARMACY_AUTORZIE_TOKEN'; static const String h2oUnit = 'H2O_UNIT'; static const String h2oReminder = 'H2O_REMINDER'; + static const String waterReminderEnabled = 'WATER_REMINDER_ENABLED'; static const String livecareClinicData = 'LIVECARE_CLINIC_DATA'; static const String doctorScheduleDateSel = 'DOCTOR_SCHEDULE_DATE_SEL'; static const String appointmentHistoryMedical = 'APPOINTMENT_HISTORY_MEDICAL'; diff --git a/lib/core/common_models/data_points.dart b/lib/core/common_models/data_points.dart index 3f5065c..f156ecb 100644 --- a/lib/core/common_models/data_points.dart +++ b/lib/core/common_models/data_points.dart @@ -1,26 +1,26 @@ - - ///class used to provide value for the [DynamicResultChart] to plot the values class DataPoint { ///values that is displayed on the graph and dot is plotted on this final double value; + ///label shown on the bottom of the graph String label; String referenceValue; String actualValue; - String? unitOfMeasurement ; + String? unitOfMeasurement; + DateTime time; String displayTime; - DataPoint( - {required this.value, - required this.label, - required this.referenceValue, - required this.actualValue, - required this.time, - required this.displayTime, - this.unitOfMeasurement - }); + DataPoint({ + required this.value, + required this.label, + required this.actualValue, + required this.time, + required this.displayTime, + this.unitOfMeasurement, + this.referenceValue = '', + }); @override String toString() { diff --git a/lib/core/dependencies.dart b/lib/core/dependencies.dart index ebf7695..489af59 100644 --- a/lib/core/dependencies.dart +++ b/lib/core/dependencies.dart @@ -1,4 +1,5 @@ import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:get_it/get_it.dart'; import 'package:hmg_patient_app_new/core/api/api_client.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; @@ -47,7 +48,6 @@ import 'package:hmg_patient_app_new/features/todo_section/todo_section_repo.dart import 'package:hmg_patient_app_new/features/todo_section/todo_section_view_model.dart'; import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_repo.dart'; import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart'; -import 'package:hmg_patient_app_new/presentation/health_calculators/health_calculator_view_model.dart'; import 'package:hmg_patient_app_new/services/analytics/analytics_service.dart'; import 'package:hmg_patient_app_new/services/cache_service.dart'; import 'package:hmg_patient_app_new/services/dialog_service.dart'; @@ -56,6 +56,7 @@ import 'package:hmg_patient_app_new/services/firebase_service.dart'; import 'package:hmg_patient_app_new/services/localauth_service.dart'; import 'package:hmg_patient_app_new/services/logger_service.dart'; import 'package:hmg_patient_app_new/services/navigation_service.dart'; +import 'package:hmg_patient_app_new/services/notification_service.dart'; import 'package:hmg_patient_app_new/widgets/date_range_selector/viewmodel/date_range_view_model.dart'; import 'package:local_auth/local_auth.dart'; import 'package:logger/web.dart'; @@ -104,6 +105,13 @@ class AppDependencies { final sharedPreferences = await SharedPreferences.getInstance(); getIt.registerLazySingleton(() => CacheServiceImp(sharedPreferences: sharedPreferences, loggerService: getIt())); + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + getIt.registerLazySingleton(() => NotificationServiceImp( + flutterLocalNotificationsPlugin: flutterLocalNotificationsPlugin, + loggerService: getIt(), + )); + getIt.registerLazySingleton(() => ApiClientImp(appState: getIt())); getIt.registerLazySingleton( () => LocalAuthService(loggerService: getIt(), localAuth: getIt()), @@ -140,7 +148,8 @@ class AppDependencies { () => RadiologyViewModel(radiologyRepo: getIt(), errorHandlerService: getIt(), navigationService: getIt()), ); - getIt.registerLazySingleton(() => PrescriptionsViewModel(prescriptionsRepo: getIt(), errorHandlerService: getIt(), navServices: getIt())); + getIt.registerLazySingleton( + () => PrescriptionsViewModel(prescriptionsRepo: getIt(), errorHandlerService: getIt(), navServices: getIt())); getIt.registerLazySingleton(() => InsuranceViewModel(insuranceRepo: getIt(), errorHandlerService: getIt())); @@ -149,19 +158,15 @@ class AppDependencies { getIt.registerLazySingleton( () => AppointmentRatingViewModel(myAppointmentsRepo: getIt(), errorHandlerService: getIt(), appState: getIt())); - getIt.registerLazySingleton(() => AppointmentRatingViewModel(myAppointmentsRepo: getIt(), errorHandlerService: getIt(), appState: getIt())); getIt.registerLazySingleton( - () => PayfortViewModel( - payfortRepo: getIt(), - errorHandlerService: getIt(), - ), + () => PayfortViewModel(payfortRepo: getIt(), errorHandlerService: getIt()), ); getIt.registerLazySingleton( () => HabibWalletViewModel( habibWalletRepo: getIt(), - errorHandlerService: getIt(), + errorHandlerService: getIt() ), ); @@ -201,6 +206,7 @@ class AppDependencies { errorHandlerService: getIt(), localAuthService: getIt()), ); + getIt.registerLazySingleton(() => ProfileSettingsViewModel()); getIt.registerLazySingleton(() => DateRangeSelectorRangeViewModel()); @@ -208,21 +214,19 @@ class AppDependencies { getIt.registerLazySingleton(() => DoctorFilterViewModel()); getIt.registerLazySingleton( - () => AppointmentViaRegionViewmodel( - navigationService: getIt(), - appState: getIt(), - ), + () => AppointmentViaRegionViewmodel(navigationService: getIt(), appState: getIt()), ); getIt.registerLazySingleton( () => EmergencyServicesViewModel( - locationUtils: getIt(), - navServices: getIt(), - emergencyServicesRepo: getIt(), - appState: getIt(), - errorHandlerService: getIt(), - appointmentRepo: getIt(), - dialogService: getIt()), + locationUtils: getIt(), + navServices: getIt(), + emergencyServicesRepo: getIt(), + appState: getIt(), + errorHandlerService: getIt(), + appointmentRepo: getIt(), + dialogService: getIt(), + ), ); getIt.registerLazySingleton( @@ -235,9 +239,7 @@ class AppDependencies { getIt.registerLazySingleton(() => HealthCalcualtorViewModel()); - getIt.registerLazySingleton( - () => TodoSectionViewModel(todoSectionRepo: getIt(), errorHandlerService: getIt()), - ); + getIt.registerLazySingleton(() => TodoSectionViewModel(todoSectionRepo: getIt(), errorHandlerService: getIt())); getIt.registerLazySingleton( () => SymptomsCheckerViewModel( @@ -246,6 +248,7 @@ class AppDependencies { appState: getIt(), ), ); + getIt.registerLazySingleton( () => HmgServicesViewModel( bookAppointmentsRepo: getIt(), @@ -265,19 +268,8 @@ class AppDependencies { ), ); - getIt.registerLazySingleton( - () => HealthProvider(), - ); + getIt.registerLazySingleton(() => HealthProvider()); getIt.registerLazySingleton(() => WaterMonitorViewModel(waterMonitorRepo: getIt())); - - // Screen-specific VMs → Factory - // getIt.registerFactory( - // () => BookAppointmentsViewModel( - // bookAppointmentsRepo: getIt(), - // dialogService: getIt(), - // errorHandlerService: getIt(), - // ), - // ); } } diff --git a/lib/core/location_util.dart b/lib/core/location_util.dart index 487b228..9dcdbb5 100644 --- a/lib/core/location_util.dart +++ b/lib/core/location_util.dart @@ -12,8 +12,9 @@ import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/services/navigation_service.dart'; import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; -import 'package:huawei_location/huawei_location.dart' as HmsLocation show FusedLocationProviderClient, Location, LocationSettingsRequest, LocationRequest; -import 'package:location/location.dart' show Location, PermissionStatus, LocationData; +import 'package:huawei_location/huawei_location.dart' as HmsLocation + show FusedLocationProviderClient, Location, LocationSettingsRequest, LocationRequest; +import 'package:location/location.dart' show Location; import 'package:permission_handler/permission_handler.dart' show Permission, PermissionListActions, PermissionStatusGetters, openAppSettings; class LocationUtils { @@ -59,37 +60,22 @@ class LocationUtils { // } void getLocation( - {Function(LatLng)? onSuccess, - VoidCallback? onFailure, - bool isShowConfirmDialog = false, - VoidCallback? onLocationDeniedForever}) async { + {Function(LatLng)? onSuccess, VoidCallback? onFailure, bool isShowConfirmDialog = false, VoidCallback? onLocationDeniedForever}) async { this.isShowConfirmDialog = isShowConfirmDialog; if (Platform.isIOS) { - getCurrentLocation( - onFailure: onFailure, - onSuccess: onSuccess, - onLocationDeniedForever: onLocationDeniedForever); + getCurrentLocation(onFailure: onFailure, onSuccess: onSuccess, onLocationDeniedForever: onLocationDeniedForever); return; } if (await isGMSDevice ?? true) { - getCurrentLocation( - onFailure: onFailure, - onSuccess: onSuccess, - onLocationDeniedForever: onLocationDeniedForever); + getCurrentLocation(onFailure: onFailure, onSuccess: onSuccess, onLocationDeniedForever: onLocationDeniedForever); return; } - getHMSLocation( - onFailure: onFailure, - onSuccess: onSuccess, - onLocationDeniedForever: onLocationDeniedForever); + getHMSLocation(onFailure: onFailure, onSuccess: onSuccess, onLocationDeniedForever: onLocationDeniedForever); } - void getCurrentLocation( - {Function(LatLng)? onSuccess, - VoidCallback? onFailure, - VoidCallback? onLocationDeniedForever}) async { + void getCurrentLocation({Function(LatLng)? onSuccess, VoidCallback? onFailure, VoidCallback? onLocationDeniedForever}) async { var location = Location(); bool isLocationEnabled = await location.serviceEnabled(); @@ -113,14 +99,12 @@ class LocationUtils { } } else if (permissionGranted == LocationPermission.deniedForever) { appState.resetLocation(); - if(onLocationDeniedForever == null && isShowConfirmDialog){ + if (onLocationDeniedForever == null && isShowConfirmDialog) { showCommonBottomSheetWithoutHeight( title: LocaleKeys.notice.tr(context: navigationService.navigatorKey.currentContext!), navigationService.navigatorKey.currentContext!, child: Utils.getWarningWidget( - loadingText: - "Please grant location permission from app settings to see better results" - .needTranslation, + loadingText: "Please grant location permission from app settings to see better results".needTranslation, isShowActionButtons: true, onCancelTap: () { navigationService.pop(); @@ -253,10 +237,7 @@ class LocationUtils { appState.userLong = locationData.longitude; } - void getHMSLocation( - {VoidCallback? onFailure, - Function(LatLng p1)? onSuccess, - VoidCallback? onLocationDeniedForever}) async { + void getHMSLocation({VoidCallback? onFailure, Function(LatLng p1)? onSuccess, VoidCallback? onLocationDeniedForever}) async { try { var location = Location(); HmsLocation.FusedLocationProviderClient locationService = HmsLocation.FusedLocationProviderClient()..initFusedLocationService(); @@ -279,14 +260,12 @@ class LocationUtils { permissionGranted = await Geolocator.requestPermission(); if (permissionGranted == LocationPermission.deniedForever) { appState.resetLocation(); - if(onLocationDeniedForever == null && isShowConfirmDialog){ + if (onLocationDeniedForever == null && isShowConfirmDialog) { showCommonBottomSheetWithoutHeight( title: LocaleKeys.notice.tr(context: navigationService.navigatorKey.currentContext!), navigationService.navigatorKey.currentContext!, child: Utils.getWarningWidget( - loadingText: - "Please grant location permission from app settings to see better results" - .needTranslation, + loadingText: "Please grant location permission from app settings to see better results".needTranslation, isShowActionButtons: true, onCancelTap: () { navigationService.pop(); @@ -311,7 +290,7 @@ class LocationUtils { HmsLocation.Location data = await locationService.getLastLocation(); if (data.latitude == null || data.longitude == null) { - appState.resetLocation(); + appState.resetLocation(); HmsLocation.LocationRequest request = HmsLocation.LocationRequest() ..priority = HmsLocation.LocationRequest.PRIORITY_HIGH_ACCURACY ..interval = 1000 // 1 second diff --git a/lib/core/post_params_model.dart b/lib/core/post_params_model.dart index cf52306..e13eb5c 100644 --- a/lib/core/post_params_model.dart +++ b/lib/core/post_params_model.dart @@ -14,19 +14,20 @@ class PostParamsModel { String? sessionID; String? setupID; - PostParamsModel( - {this.versionID, - this.channel, - this.languageID, - this.logInTokenID, - this.tokenID, - this.language, - this.ipAddress, - this.generalId, - this.latitude, - this.longitude, - this.deviceTypeID, - this.sessionID}); + PostParamsModel({ + this.versionID, + this.channel, + this.languageID, + this.logInTokenID, + this.tokenID, + this.language, + this.ipAddress, + this.generalId, + this.latitude, + this.longitude, + this.deviceTypeID, + this.sessionID, + }); PostParamsModel.fromJson(Map json) { versionID = json['VersionID']; diff --git a/lib/core/utils/date_util.dart b/lib/core/utils/date_util.dart index a42a44d..746d2a7 100644 --- a/lib/core/utils/date_util.dart +++ b/lib/core/utils/date_util.dart @@ -6,8 +6,6 @@ class DateUtil { /// convert String To Date function /// [date] String we want to convert static DateTime convertStringToDate(String? date) { - - if (date == null) return DateTime.now(); if (date.isEmpty) return DateTime.now(); @@ -522,6 +520,64 @@ class DateUtil { } return ""; } + + /// Get short month name from full month name + /// [monthName] Full month name like "January" + /// Returns short form like "Jan" + static String getShortMonthName(String monthName) { + switch (monthName.toLowerCase()) { + case 'january': + return 'Jan'; + case 'february': + return 'Feb'; + case 'march': + return 'Mar'; + case 'april': + return 'Apr'; + case 'may': + return 'May'; + case 'june': + return 'Jun'; + case 'july': + return 'Jul'; + case 'august': + return 'Aug'; + case 'september': + return 'Sep'; + case 'october': + return 'Oct'; + case 'november': + return 'Nov'; + case 'december': + return 'Dec'; + default: + return monthName; // Return as-is if not recognized + } + } + + /// Get short weekday name from full weekday name + /// [weekDayName] Full weekday name like "Monday" + /// Returns short form like "Mon" + static String getShortWeekDayName(String weekDayName) { + switch (weekDayName.toLowerCase().trim()) { + case 'monday': + return 'Mon'; + case 'tuesday': + return 'Tue'; + case 'wednesday': + return 'Wed'; + case 'thursday': + return 'Thu'; + case 'friday': + return 'Fri'; + case 'saturday': + return 'Sat'; + case 'sunday': + return 'Sun'; + default: + return weekDayName; // Return as-is if not recognized + } + } } extension OnlyDate on DateTime { diff --git a/lib/core/utils/local_notifications.dart b/lib/core/utils/local_notifications.dart deleted file mode 100644 index aba01f8..0000000 --- a/lib/core/utils/local_notifications.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; - -final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - -class LocalNotification { - Function(String payload)? _onNotificationClick; - static LocalNotification? _instance; - - static LocalNotification? getInstance() { - return _instance; - } - - static init({required Function(String payload) onNotificationClick}) { - if (_instance == null) { - _instance = LocalNotification(); - _instance?._onNotificationClick = onNotificationClick; - _instance?._initialize(); - } else { - // assert(false,(){ - // //TODO fix it - // "LocalNotification Already Initialized"; - // }); - } - } - - _initialize() async { - try { - var initializationSettingsAndroid = new AndroidInitializationSettings('app_icon'); - var initializationSettingsIOS = DarwinInitializationSettings(); - var initializationSettings = InitializationSettings(android: initializationSettingsAndroid, iOS: initializationSettingsIOS); - await flutterLocalNotificationsPlugin.initialize( - initializationSettings, - onDidReceiveNotificationResponse: (NotificationResponse notificationResponse) { - switch (notificationResponse.notificationResponseType) { - case NotificationResponseType.selectedNotification: - // selectNotificationStream.add(notificationResponse.payload); - break; - case NotificationResponseType.selectedNotificationAction: - // if (notificationResponse.actionId == navigationActionId) { - // selectNotificationStream.add(notificationResponse.payload); - // } - break; - } - }, - // onDidReceiveBackgroundNotificationResponse: notificationTapBackground, - ); - } catch (ex) { - print(ex.toString()); - } - // flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: (NotificationResponse notificationResponse) - // { - // switch (notificationResponse.notificationResponseType) { - // case NotificationResponseType.selectedNotification: - // // selectNotificationStream.add(notificationResponse.payload); - // break; - // case NotificationResponseType.selectedNotificationAction: - // // if (notificationResponse.actionId == navigationActionId) { - // // selectNotificationStream.add(notificationResponse.payload); - // } - // // break; - // },} - // - // , - // - // ); - } - - // void notificationTapBackground(NotificationResponse notificationResponse) { - // // ignore: avoid_print - // print('notification(${notificationResponse.id}) action tapped: ' - // '${notificationResponse.actionId} with' - // ' payload: ${notificationResponse.payload}'); - // if (notificationResponse.input?.isNotEmpty ?? false) { - // // ignore: avoid_print - // print('notification action tapped with input: ${notificationResponse.input}'); - // } - // } - - var _random = new Random(); - - _randomNumber({int from = 100000}) { - return _random.nextInt(from); - } - - _vibrationPattern() { - var vibrationPattern = Int64List(4); - vibrationPattern[0] = 0; - vibrationPattern[1] = 1000; - vibrationPattern[2] = 5000; - vibrationPattern[3] = 2000; - - return vibrationPattern; - } - - Future? showNow({required String title, required String subtitle, required String payload}) { - Future.delayed(Duration(seconds: 1)).then((result) async { - var androidPlatformChannelSpecifics = AndroidNotificationDetails( - 'com.hmg.local_notification', - 'HMG', - channelDescription: 'HMG', - importance: Importance.max, - priority: Priority.high, - ticker: 'ticker', - vibrationPattern: _vibrationPattern(), - ongoing: true, - autoCancel: false, - usesChronometer: true, - when: DateTime.now().millisecondsSinceEpoch - 120 * 1000, - ); - var iOSPlatformChannelSpecifics = DarwinNotificationDetails(); - var platformChannelSpecifics = NotificationDetails(android: androidPlatformChannelSpecifics, iOS: iOSPlatformChannelSpecifics); - await flutterLocalNotificationsPlugin.show(25613, title, subtitle, platformChannelSpecifics, payload: payload).catchError((err) { - print(err); - }); - }); - } - - Future scheduleNotification({required DateTime scheduledNotificationDateTime, required String title, required String description}) async { - ///vibrationPattern - var vibrationPattern = Int64List(4); - vibrationPattern[0] = 0; - vibrationPattern[1] = 1000; - vibrationPattern[2] = 5000; - vibrationPattern[3] = 2000; - - // var androidPlatformChannelSpecifics = AndroidNotificationDetails('active-prescriptions', 'ActivePrescriptions', - // channelDescription: 'ActivePrescriptionsDescription', - // // icon: 'secondary_icon', - // sound: RawResourceAndroidNotificationSound('slow_spring_board'), - // - // ///change it to be as ionic - // // largeIcon: DrawableResourceAndroidBitmap('sample_large_icon'),///change it to be as ionic - // vibrationPattern: vibrationPattern, - // enableLights: true, - // color: const Color.fromARGB(255, 255, 0, 0), - // ledColor: const Color.fromARGB(255, 255, 0, 0), - // ledOnMs: 1000, - // ledOffMs: 500); - // var iOSPlatformChannelSpecifics = DarwinNotificationDetails(sound: 'slow_spring_board.aiff'); - - // /change it to be as ionic - // var platformChannelSpecifics = NotificationDetails(android: androidPlatformChannelSpecifics, iOS: iOSPlatformChannelSpecifics); - // await flutterLocalNotificationsPlugin.schedule(0, title, description, scheduledNotificationDateTime, platformChannelSpecifics); - } - - ///Repeat notification every day at approximately 10:00:00 am - Future showDailyAtTime() async { - // var time = Time(10, 0, 0); - // var androidPlatformChannelSpecifics = AndroidNotificationDetails('repeatDailyAtTime channel id', 'repeatDailyAtTime channel name', channelDescription: 'repeatDailyAtTime description'); - // var iOSPlatformChannelSpecifics = DarwinNotificationDetails(); - // var platformChannelSpecifics = NotificationDetails( - // androidPlatformChannelSpecifics, iOSPlatformChannelSpecifics); - // await flutterLocalNotificationsPlugin.showDailyAtTime( - // 0, - // 'show daily title', - // 'Daily notification shown at approximately ${_toTwoDigitString(time.hour)}:${_toTwoDigitString(time.minute)}:${_toTwoDigitString(time.second)}', - // time, - // platformChannelSpecifics); - } - - ///Repeat notification weekly on Monday at approximately 10:00:00 am - Future showWeeklyAtDayAndTime() async { - // var time = Time(10, 0, 0); - // var androidPlatformChannelSpecifics = AndroidNotificationDetails('show weekly channel id', 'show weekly channel name', channelDescription: 'show weekly description'); - // var iOSPlatformChannelSpecifics = DarwinNotificationDetails(); - // var platformChannelSpecifics = NotificationDetails( - // androidPlatformChannelSpecifics, iOSPlatformChannelSpecifics); - // await flutterLocalNotificationsPlugin.showWeeklyAtDayAndTime( - // 0, - // 'show weekly title', - // 'Weekly notification shown on Monday at approximately ${_toTwoDigitString(time.hour)}:${_toTwoDigitString(time.minute)}:${_toTwoDigitString(time.second)}', - // Day.Monday, - // time, - // platformChannelSpecifics); - } - - String _toTwoDigitString(int value) { - return value.toString().padLeft(2, '0'); - } - - Future cancelNotification() async { - await flutterLocalNotificationsPlugin.cancel(0); - } - - Future cancelAllNotifications() async { - await flutterLocalNotificationsPlugin.cancelAll(); - } -} diff --git a/lib/core/utils/push_notification_handler.dart b/lib/core/utils/push_notification_handler.dart index a96b805..88e8cc8 100644 --- a/lib/core/utils/push_notification_handler.dart +++ b/lib/core/utils/push_notification_handler.dart @@ -15,16 +15,11 @@ import 'package:flutter_callkit_incoming/entities/notification_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_ios_voip_kit_karmm/call_state_type.dart'; import 'package:flutter_ios_voip_kit_karmm/flutter_ios_voip_kit.dart'; -// import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; - -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:hmg_patient_app_new/core/utils/local_notifications.dart'; +import 'package:hmg_patient_app_new/core/cache_consts.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:uuid/uuid.dart'; -import '../cache_consts.dart'; - // |--> Push Notification Background @pragma('vm:entry-point') Future backgroundMessageHandler(dynamic message) async { @@ -36,7 +31,7 @@ Future backgroundMessageHandler(dynamic message) async { // showCallkitIncoming(message); _incomingCall(message.data); return; - } else {} + } } callPage(String sessionID, String token) async {} @@ -323,7 +318,7 @@ class PushNotificationHandler { if (fcmToken != null) onToken(fcmToken); // } } catch (ex) { - print("Notification Exception: " + ex.toString()); + print("Notification Exception: $ex"); } FirebaseMessaging.onBackgroundMessage(backgroundMessageHandler); } @@ -331,7 +326,7 @@ class PushNotificationHandler { if (Platform.isIOS) { final permission = await FirebaseMessaging.instance.requestPermission(); await FirebaseMessaging.instance.getAPNSToken().then((value) async { - log("APNS token: " + value.toString()); + log("APNS token: $value"); await Utils.saveStringFromPrefs(CacheConst.apnsToken, value.toString()); }); await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions( @@ -378,14 +373,14 @@ class PushNotificationHandler { }); FirebaseMessaging.instance.getToken().then((String? token) { - print("Push Notification getToken: " + token!); + print("Push Notification getToken: ${token!}"); onToken(token!); }).catchError((err) { print(err); }); FirebaseMessaging.instance.onTokenRefresh.listen((fcm_token) { - print("Push Notification onTokenRefresh: " + fcm_token); + print("Push Notification onTokenRefresh: $fcm_token"); onToken(fcm_token); }); @@ -401,7 +396,7 @@ class PushNotificationHandler { } newMessage(RemoteMessage remoteMessage) async { - print("Remote Message: " + remoteMessage.data.toString()); + print("Remote Message: ${remoteMessage.data}"); if (remoteMessage.data.isEmpty) { return; } @@ -427,7 +422,7 @@ class PushNotificationHandler { } onToken(String token) async { - print("Push Notification Token: " + token); + print("Push Notification Token: $token"); await Utils.saveStringFromPrefs(CacheConst.pushToken, token); } @@ -441,9 +436,7 @@ class PushNotificationHandler { Future requestPermissions() async { try { if (Platform.isIOS) { - await flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation() - ?.requestPermissions(alert: true, badge: true, sound: true); + await FirebaseMessaging.instance.requestPermission(alert: true, badge: true, sound: true); } else if (Platform.isAndroid) { Map statuses = await [ Permission.notification, diff --git a/lib/core/utils/utils.dart b/lib/core/utils/utils.dart index 38d04b9..5a99cda 100644 --- a/lib/core/utils/utils.dart +++ b/lib/core/utils/utils.dart @@ -351,10 +351,10 @@ class Utils { ).center; } - static Widget getSuccessWidget({String? loadingText}) { + static Widget getSuccessWidget({String? loadingText, CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center}) { return Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: crossAxisAlignment, children: [ Lottie.asset(AppAnimations.checkmark, repeat: true, reverse: false, frameRate: FrameRate(60), width: 100.h, height: 100.h, fit: BoxFit.fill), SizedBox(height: 8.h), @@ -876,7 +876,6 @@ class Utils { launchUrl(uri, mode: LaunchMode.inAppBrowserView); } - static Color getCardBorderColor(int currentQueueStatus) { switch (currentQueueStatus) { case 0: diff --git a/lib/extensions/string_extensions.dart b/lib/extensions/string_extensions.dart index b4442cb..75c57a7 100644 --- a/lib/extensions/string_extensions.dart +++ b/lib/extensions/string_extensions.dart @@ -23,14 +23,15 @@ extension CapExtension on String { extension EmailValidator on String { Widget get toWidget => Text(this); - Widget toText8({Color? color, bool isBold = false, int? maxlines, FontStyle? fontStyle, TextOverflow? textOverflow}) => Text( + Widget toText8({Color? color, FontWeight? fontWeight, bool isBold = false, int? maxlines, FontStyle? fontStyle, TextOverflow? textOverflow}) => + Text( this, maxLines: maxlines, overflow: textOverflow, style: TextStyle( fontSize: 8.f, fontStyle: fontStyle ?? FontStyle.normal, - fontWeight: isBold ? FontWeight.bold : FontWeight.normal, + fontWeight: fontWeight ?? (isBold ? FontWeight.bold : FontWeight.normal), color: color ?? AppColors.blackColor, letterSpacing: 0, ), diff --git a/lib/features/water_monitor/water_monitor_repo.dart b/lib/features/water_monitor/water_monitor_repo.dart index f52c971..73c2ea2 100644 --- a/lib/features/water_monitor/water_monitor_repo.dart +++ b/lib/features/water_monitor/water_monitor_repo.dart @@ -74,7 +74,6 @@ class WaterMonitorRepoImp implements WaterMonitorRepo { }, onSuccess: (response, statusCode, {messageStatus, errorMessage}) { try { - log("response h2oGetUserDetail: ${response.toString()}"); // Extract only the specific key from the API response as requested dynamic extracted; if (response is Map && response.containsKey('UserDetailData_New')) { diff --git a/lib/features/water_monitor/water_monitor_view_model.dart b/lib/features/water_monitor/water_monitor_view_model.dart index 4ad8f1e..c079b5e 100644 --- a/lib/features/water_monitor/water_monitor_view_model.dart +++ b/lib/features/water_monitor/water_monitor_view_model.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; +import 'package:hmg_patient_app_new/core/cache_consts.dart'; +import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/utils/date_util.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/features/water_monitor/models/insert_user_activity_request_model.dart'; @@ -13,7 +15,9 @@ import 'package:hmg_patient_app_new/features/water_monitor/models/user_progress_ import 'package:hmg_patient_app_new/features/water_monitor/models/water_cup_model.dart'; import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_repo.dart'; import 'package:hmg_patient_app_new/routes/app_routes.dart'; +import 'package:hmg_patient_app_new/services/cache_service.dart'; import 'package:hmg_patient_app_new/services/navigation_service.dart'; +import 'package:hmg_patient_app_new/services/notification_service.dart'; class WaterMonitorViewModel extends ChangeNotifier { WaterMonitorRepo waterMonitorRepo; @@ -78,9 +82,11 @@ class WaterMonitorViewModel extends ChangeNotifier { // Network/data final AppState _appState = GetIt.instance(); final NavigationService _navigationService = GetIt.instance(); + final CacheService _cacheService = GetIt.instance(); bool _isLoading = false; dynamic _userDetailData; + bool _isWaterReminderEnabled = false; // Progress data lists List _todayProgressList = []; @@ -92,6 +98,8 @@ class WaterMonitorViewModel extends ChangeNotifier { dynamic get userDetailData => _userDetailData; + bool get isWaterReminderEnabled => _isWaterReminderEnabled; + // Getters for progress data List get todayProgressList => _todayProgressList; @@ -117,12 +125,19 @@ class WaterMonitorViewModel extends ChangeNotifier { // Initialize method to be called when needed Future initialize() async { - _loadSettings(); _initializeDefaultCups(); + _loadReminderEnabledState(); await fetchUserDetailsForMonitoring(); + // Fetch daily progress to get consumed amount and daily goal await fetchUserProgressForMonitoring(); } + /// Load reminder enabled state from cache + void _loadReminderEnabledState() { + _isWaterReminderEnabled = _cacheService.getBool(key: CacheConst.waterReminderEnabled) ?? false; + log('Water reminder enabled state loaded: $_isWaterReminderEnabled'); + } + /// Map selected duration to ProgressType enum ProgressType _getProgressTypeFromDuration() { switch (_selectedDuration) { @@ -150,7 +165,7 @@ class WaterMonitorViewModel extends ChangeNotifier { return; } - final mobile = (authenticated.mobileNumber ?? '').replaceAll('+', ''); + final mobile = (authenticated.mobileNumber ?? '').replaceAll('+', '').replaceFirst(RegExp(r'^0'), ''); final identification = authenticated.patientIdentificationNo ?? ''; final progressType = _getProgressTypeFromDuration(); @@ -177,6 +192,19 @@ class WaterMonitorViewModel extends ChangeNotifier { } } + // Update consumed amount and daily goal from API response + if (_todayProgressList.isNotEmpty) { + final todayData = _todayProgressList.first; + if (todayData.quantityConsumed != null) { + _totalConsumedMl = todayData.quantityConsumed!.toInt(); + log('Updated consumed from API: $_totalConsumedMl ml'); + } + if (todayData.quantityLimit != null) { + _dailyGoalMl = todayData.quantityLimit!.toInt(); + log('Updated daily goal from API: $_dailyGoalMl ml'); + } + } + break; case ProgressType.week: @@ -231,19 +259,11 @@ class WaterMonitorViewModel extends ChangeNotifier { result.fold((failure) { _userDetailData = null; }, (apiModel) { - log("_userDetailData: ${apiModel.data.toString()}"); _userDetailData = apiModel.data; // Populate form fields from the fetched data if (_userDetailData != null) { _populateFormFields(_userDetailData); - - // Calculate and set daily water goal based on the fetched user details - final calculatedGoal = calculateDailyWaterIntake(); - log("calculatedGoal: $calculatedGoal"); - if (calculatedGoal > 0) { - setDailyGoal(calculatedGoal.toInt()); - } } }); } catch (e) { @@ -514,49 +534,6 @@ class WaterMonitorViewModel extends ChangeNotifier { return null; } - // Calculate water intake based on user data - double calculateDailyWaterIntake() { - if (!isValid) return 0; - - final weight = double.parse(weightController.text.trim()); - final weightInKg = _convertWeightToKg(weight, _selectedWeightUnit); - - // Base calculation: 30-35ml per kg of body weight - double baseIntake = weightInKg * 33; - - // Adjust for activity level - double activityMultiplier = _getActivityMultiplier(); - double totalIntake = baseIntake * activityMultiplier; - - return totalIntake; // in ml - } - - double _convertWeightToKg(double weight, String unit) { - switch (unit) { - case 'kg': - return weight; - case 'lb': - return weight * 0.453592; - default: - return weight; - } - } - - double _getActivityMultiplier() { - switch (_selectedActivityLevel) { - case "Almost Inactive (no exercise)": - return 1.0; - case "Lightly active": - return 1.1; - case "Lightly active (1-3) days per week": - return 1.2; - case "Super active (very hard exercise)": - return 1.4; - default: - return 1.1; - } - } - String _getApiCompatibleGender() { if (_selectedGender == "Female") { return "F"; @@ -624,16 +601,12 @@ class WaterMonitorViewModel extends ChangeNotifier { notifyListeners(); return false; }, - (apiModel) { + (apiModel) async { // Update local data with response _userDetailData = apiModel.data; - // Calculate and set daily goal based on the inputs - final calculatedGoal = calculateDailyWaterIntake(); - log("calculatedGoal: $calculatedGoal"); - if (calculatedGoal > 0) { - setDailyGoal(calculatedGoal.toInt()); - } + // Fetch daily progress to get the updated goal and consumed data from API + await fetchUserProgressForMonitoring(); _isLoading = false; notifyListeners(); @@ -678,11 +651,6 @@ class WaterMonitorViewModel extends ChangeNotifier { return ""; } - // Load settings - Future _loadSettings() async { - notifyListeners(); - } - @override void dispose() { nameController.dispose(); @@ -694,8 +662,8 @@ class WaterMonitorViewModel extends ChangeNotifier { List _cups = []; String? _selectedCupId; - int _totalConsumedMl = 1000; - int _dailyGoalMl = 2000; + int _totalConsumedMl = 0; // Loaded from API + int _dailyGoalMl = 0; // Loaded from API // Calibration: portion of the bottle SVG height that is fillable (0.0 - 1.0) double _fillableHeightPercent = 0.7; @@ -788,7 +756,6 @@ class WaterMonitorViewModel extends ChangeNotifier { return "${wakingHoursStart.toString().padLeft(2, '0')}:00 AM"; } - // If after waking hours, next drink is tomorrow morning if (currentHour >= wakingHoursEnd) { return "Tomorrow ${wakingHoursStart.toString().padLeft(2, '0')}:00 AM"; } @@ -956,13 +923,6 @@ class WaterMonitorViewModel extends ChangeNotifier { notifyListeners(); } - void addWaterIntake() { - if (selectedCup != null) { - _totalConsumedMl += selectedCup!.capacityMl; - notifyListeners(); - } - } - // Returns the currently selected cup capacity in ml (0 if none) int get selectedCupCapacityMl => selectedCup?.capacityMl ?? 0; @@ -982,11 +942,6 @@ class WaterMonitorViewModel extends ChangeNotifier { } } - void resetDaily() { - _totalConsumedMl = 0; - notifyListeners(); - } - /// Insert user activity (record water intake) Future insertUserActivity({required int quantityIntake}) async { try { @@ -1023,16 +978,29 @@ class WaterMonitorViewModel extends ChangeNotifier { }, (apiModel) { log("Insert user activity success: ${apiModel.data.toString()}"); - // Update consumed amount from the response + // Update consumed amount and goal from the response if (apiModel.data != null && apiModel.data is List && (apiModel.data as List).isNotEmpty) { final progressData = (apiModel.data as List).first; - if (progressData is Map && progressData.containsKey('QuantityConsumed')) { - final consumed = progressData['QuantityConsumed']; - if (consumed != null) { - _totalConsumedMl = (consumed is num) ? consumed.toInt() : int.tryParse(consumed.toString()) ?? _totalConsumedMl; + if (progressData is Map) { + // Update consumed amount + if (progressData.containsKey('QuantityConsumed')) { + final consumed = progressData['QuantityConsumed']; + if (consumed != null) { + _totalConsumedMl = (consumed is num) ? consumed.toInt() : int.tryParse(consumed.toString()) ?? _totalConsumedMl; + log('Updated consumed after insert: $_totalConsumedMl ml'); + } + } + // Update daily goal + if (progressData.containsKey('QuantityLimit')) { + final limit = progressData['QuantityLimit']; + if (limit != null) { + _dailyGoalMl = (limit is num) ? limit.toInt() : int.tryParse(limit.toString()) ?? _dailyGoalMl; + log('Updated daily goal after insert: $_dailyGoalMl ml'); + } } } + // Refresh progress data to ensure consistency fetchUserProgressForMonitoring(); } @@ -1086,13 +1054,25 @@ class WaterMonitorViewModel extends ChangeNotifier { (apiModel) { log("Undo user activity success: ${apiModel.data.toString()}"); - // Update consumed amount from the response + // Update consumed amount and goal from the response if (apiModel.data != null && apiModel.data is List && (apiModel.data as List).isNotEmpty) { final progressData = (apiModel.data as List).first; - if (progressData is Map && progressData.containsKey('QuantityConsumed')) { - final consumed = progressData['QuantityConsumed']; - if (consumed != null) { - _totalConsumedMl = (consumed is num) ? consumed.toInt() : int.tryParse(consumed.toString()) ?? _totalConsumedMl; + if (progressData is Map) { + // Update consumed amount + if (progressData.containsKey('QuantityConsumed')) { + final consumed = progressData['QuantityConsumed']; + if (consumed != null) { + _totalConsumedMl = (consumed is num) ? consumed.toInt() : int.tryParse(consumed.toString()) ?? _totalConsumedMl; + log('Updated consumed after undo: $_totalConsumedMl ml'); + } + } + // Update daily goal + if (progressData.containsKey('QuantityLimit')) { + final limit = progressData['QuantityLimit']; + if (limit != null) { + _dailyGoalMl = (limit is num) ? limit.toInt() : int.tryParse(limit.toString()) ?? _dailyGoalMl; + log('Updated daily goal after undo: $_dailyGoalMl ml'); + } } } } @@ -1109,4 +1089,163 @@ class WaterMonitorViewModel extends ChangeNotifier { return false; } } + + /// Schedule water reminders based on user's reminder settings + Future scheduleWaterReminders() async { + try { + final notificationService = getIt.get(); + + // Request permission first + final hasPermission = await notificationService.requestPermissions(); + if (!hasPermission) { + log('Notification permission denied'); + return false; + } + + // Calculate reminder times based on _selectedNumberOfReminders + final reminderTimes = _calculateReminderTimes(); + + if (reminderTimes.isEmpty) { + log('No reminder times calculated'); + return false; + } + + // Schedule water reminders + await notificationService.scheduleWaterReminders( + reminderTimes: reminderTimes, + title: 'Time to Drink Water! 💧'.needTranslation, + body: 'Stay hydrated! Drink ${selectedCupCapacityMl}ml of water.'.needTranslation, + ); + + // Save reminder enabled state to cache + _isWaterReminderEnabled = true; + await _cacheService.saveBool(key: CacheConst.waterReminderEnabled, value: true); + + log('Scheduled ${reminderTimes.length} water reminders successfully'); + notifyListeners(); + return true; + } catch (e) { + log('Exception in scheduleWaterReminders: $e'); + return false; + } + } + + /// Calculate reminder times based on selected number of reminders + List _calculateReminderTimes() { + try { + final remindersPerDay = int.tryParse(_selectedNumberOfReminders.replaceAll(' Time', '').trim()) ?? 3; + + const wakingHoursStart = 6; // 6 AM + const wakingHoursEnd = 22; // 10 PM + const totalWakingHours = wakingHoursEnd - wakingHoursStart; + + final intervalHours = totalWakingHours / remindersPerDay; + + List times = []; + final now = DateTime.now(); + + for (int i = 0; i < remindersPerDay; i++) { + final hourDecimal = wakingHoursStart + (i * intervalHours); + final hour = hourDecimal.floor(); + final minute = ((hourDecimal - hour) * 60).round(); + + final reminderTime = DateTime( + now.year, + now.month, + now.day, + hour, + minute, + ); + + times.add(reminderTime); + } + + return times; + } catch (e) { + log('Error calculating reminder times: $e'); + return []; + } + } + + /// Cancel all water reminders + Future cancelWaterReminders() async { + try { + final notificationService = GetIt.instance(); + + // Get pending notifications and cancel water reminders (IDs 5000-5999) + final pendingNotifications = await notificationService.getPendingNotifications(); + for (final notification in pendingNotifications) { + if (notification.id >= 5000 && notification.id < 6000) { + await notificationService.cancelNotification(notification.id); + } + } + + // Save reminder disabled state to cache + _isWaterReminderEnabled = false; + await _cacheService.saveBool(key: CacheConst.waterReminderEnabled, value: false); + + log('Cancelled all water reminders'); + notifyListeners(); + return true; + } catch (e) { + log('Exception in cancelWaterReminders: $e'); + return false; + } + } + + /// Get list of scheduled water reminder times + Future> getScheduledReminderTimes() async { + try { + final notificationService = GetIt.instance(); + final pendingNotifications = await notificationService.getPendingNotifications(); + + List times = []; + for (final notification in pendingNotifications) { + if (notification.id >= 5000 && notification.id < 6000) { + // Note: PendingNotificationRequest doesn't contain scheduled time + // We can only return the calculated times based on current settings + times = _calculateReminderTimes(); + break; + } + } + + return times; + } catch (e) { + log('Exception in getScheduledReminderTimes: $e'); + return []; + } + } + + /// Schedule a test notification after 5 seconds + /// Useful for testing notification functionality + Future scheduleTestNotification() async { + try { + final notificationService = GetIt.instance(); + + // Request permission first + final hasPermission = await notificationService.requestPermissions(); + if (!hasPermission) { + log('Notification permission denied for test notification'); + return false; + } + + // Schedule notification 5 seconds from now + final scheduledTime = DateTime.now().add(const Duration(seconds: 5)); + + await notificationService.scheduleNotification( + id: 9999, + // Use a unique ID for test notifications + title: 'Time to Drink Water! 💧'.needTranslation, + body: 'Stay hydrated! Drink ${selectedCupCapacityMl}ml of water.'.needTranslation, + scheduledDate: scheduledTime, + payload: 'test_notification', + ); + + log('Test notification scheduled for 5 seconds from now'); + return true; + } catch (e) { + log('Exception in scheduleTestNotification: $e'); + return false; + } + } } diff --git a/lib/main.dart b/lib/main.dart index 20daf63..9600d5e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,7 +33,6 @@ import 'package:hmg_patient_app_new/features/smartwatch_health_data/health_provi import 'package:hmg_patient_app_new/features/symptoms_checker/symptoms_checker_view_model.dart'; import 'package:hmg_patient_app_new/features/todo_section/todo_section_view_model.dart'; import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart'; -import 'package:hmg_patient_app_new/presentation/health_calculators/health_calculator_view_model.dart'; import 'package:hmg_patient_app_new/presentation/health_calculators_and_converts/health_calculator_view_model.dart'; import 'package:hmg_patient_app_new/routes/app_routes.dart'; import 'package:hmg_patient_app_new/services/logger_service.dart'; @@ -73,7 +72,7 @@ Future callInitializations() async { WidgetsFlutterBinding.ensureInitialized(); await EasyLocalization.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - AppDependencies.addDependencies(); + await AppDependencies.addDependencies(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); HttpOverrides.global = MyHttpOverrides(); await callAppStateInitializations(); @@ -192,11 +191,7 @@ class MyApp extends StatelessWidget { return MaterialApp( title: 'Dr. AlHabib', builder: (context, mchild) { - return MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(1.0), - ), - child: mchild!); + return MediaQuery(data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), child: mchild!); }, showSemanticsDebugger: false, debugShowCheckedModeBanner: false, diff --git a/lib/presentation/water_monitor/water_consumption_screen.dart b/lib/presentation/water_monitor/water_consumption_screen.dart index 625839e..a7a5e60 100644 --- a/lib/presentation/water_monitor/water_consumption_screen.dart +++ b/lib/presentation/water_monitor/water_consumption_screen.dart @@ -1,22 +1,23 @@ -import 'dart:developer'; -import 'dart:math' as math; - +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; import 'package:hmg_patient_app_new/core/app_export.dart'; +import 'package:hmg_patient_app_new/core/common_models/data_points.dart'; import 'package:hmg_patient_app_new/core/dependencies.dart'; +import 'package:hmg_patient_app_new/core/utils/date_util.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; -import 'package:hmg_patient_app_new/extensions/route_extensions.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart'; -import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/bottle_shape_clipper.dart'; -import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/cup_bottomsheet_widgets.dart'; -import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/water_consumption_progress_widget.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/hydration_tips_widget.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/water_intake_summary_widget.dart'; import 'package:hmg_patient_app_new/services/dialog_service.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart'; import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; +import 'package:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart'; +import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; +import 'package:hmg_patient_app_new/widgets/graph/custom_graph.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; @@ -32,360 +33,39 @@ class _WaterConsumptionScreenState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { - final vm = context.read(); - await vm.initialize(); - log('WaterMonitor initialized: consumed=${vm.totalConsumedMl}, goal=${vm.dailyGoalMl}, progress=${vm.progressPercent}'); + await _refreshData(); }); } - Widget _buildHydrationTipsWidget() { - return Container( - margin: EdgeInsets.symmetric(horizontal: 24.w), - padding: EdgeInsets.all(16.h), - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 24.r, - hasShadow: true, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Utils.buildSvgWithAssets(icon: AppAssets.bulb_icon, width: 24.w, height: 24.h), - SizedBox(width: 8.w), - "Tips to stay hydrated".needTranslation.toText16(isBold: true), - ], - ), - SizedBox(height: 8.h), - " • ${"Drink before you feel thirsty"}".needTranslation.toText12( - fontWeight: FontWeight.w500, - color: AppColors.textColorLight, - ), - SizedBox(height: 4.h), - " • ${"Keep a refillable bottle next to you"}".needTranslation.toText12( - fontWeight: FontWeight.w500, - color: AppColors.textColorLight, - ), - SizedBox(height: 4.h), - " • ${"Track your daily intake to stay motivated"}".needTranslation.toText12( - fontWeight: FontWeight.w500, - color: AppColors.textColorLight, - ), - SizedBox(height: 4.h), - " • ${"Choose sparkling water instead of soda"}".needTranslation.toText12( - fontWeight: FontWeight.w500, - color: AppColors.textColorLight, - ), - SizedBox(height: 8.h), - ], - ), - ); - } - - Widget _buildWaterIntakeSummaryWidget() { - return Container( - width: double.infinity, - padding: EdgeInsets.all(24.w), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.blueGradientColorOne, - AppColors.blueGradientColorTwo, - ], - ), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - flex: isTablet ? 2 : 3, - child: Consumer(builder: (context, vm, _) { - if (vm.isLoading) { - // show shimmer placeholder while fetching server-side user details - return _buildLoadingShimmer(isForHistory: false); - } - - final goalMl = vm.dailyGoalMl; - final consumed = vm.totalConsumedMl; - final remaining = (goalMl - consumed) > 0 ? (goalMl - consumed) : 0; - final completedPercent = "${(vm.progress * 100).clamp(0.0, 100.0).toStringAsFixed(0)}%"; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - "Next Drink Time".toText18(weight: FontWeight.w600, color: AppColors.textColor), - vm.nextDrinkTime.toText32(weight: FontWeight.w600, color: AppColors.blueColor), - SizedBox(height: 12.h), - _buildStatusColumn(title: "Your Goal".needTranslation, subTitle: "${goalMl}ml"), - SizedBox(height: 8.h), - _buildStatusColumn(title: "Remaining".needTranslation, subTitle: "${remaining}ml"), - SizedBox(height: 8.h), - _buildStatusColumn(title: "Completed".needTranslation, subTitle: completedPercent, subTitleColor: AppColors.successColor), - SizedBox(height: 8.h), - _buildStatusColumn( - title: "Hydration Status".needTranslation, - subTitle: vm.hydrationStatus, - subTitleColor: vm.hydrationStatusColor, - ), - ], - ); - }), - ), - SizedBox(width: isTablet ? 32 : 16.w), - Expanded( - flex: isTablet ? 1 : 2, - child: _buildWaterBottleWidget(), - ), - ], - ), - _buildBottomActionWidgets(), - ], - ), - ); - } - - Widget _buildStatusColumn({required String title, required String subTitle, Color? subTitleColor}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - "$title: ".toText16(weight: FontWeight.w500, color: AppColors.textColor), - subTitle.toText12( - fontWeight: FontWeight.w600, - color: subTitleColor ?? AppColors.greyTextColor, - ), - ], - ); + /// Refresh data by calling initialize on the view model + Future _refreshData() async { + final vm = context.read(); + await vm.initialize(); } - Widget _buildWaterBottleWidget() { - return Consumer( - builder: (context, vm, _) { - final progressPercent = (vm.progress * 100).clamp(0.0, 100.0); - log("progressPercent: $progressPercent"); - - // SVG aspect ratio - const svgAspectRatio = 315.0 / 143.0; // ~2.2 - - // Responsive bottle sizing with device-specific constraints - double bottleWidth; - if (isTablet) { - // Tablet: use logical pixels (not scaled) with a reasonable max - bottleWidth = math.min(SizeUtils.width * 0.15, 180.0); // 15% of width, max 180 - } else if (isFoldable) { - // Foldable: moderate scaling - bottleWidth = math.min(100.w, 160.0); - } else { - // Phone: use your responsive . w with a cap - bottleWidth = math.min(120.w, 140.0); - } - - final bottleHeight = bottleWidth * svgAspectRatio; - - // Fillable area percentages (calibrated to your SVG) - final fillableHeightPercent = 0.7; - const fillableWidthPercent = 0.8; - - final fillableHeight = bottleHeight * fillableHeightPercent; - final fillableWidth = bottleWidth * fillableWidthPercent; - - // Device-specific positioning offsets - final double leftOffset = isTablet ? 4.w : 8.w; - final double bottomOffset = isTablet ? -65.h : -78.h; - - return SizedBox( - height: bottleHeight, - width: bottleWidth, - child: Stack( - fit: StackFit.expand, - alignment: Alignment.center, - children: [ - // Bottle SVG outline - Center( - child: Utils.buildSvgWithAssets( - icon: AppAssets.waterBottle, - height: bottleHeight, - width: bottleWidth, - fit: BoxFit.contain, - ), - ), - - // Wave and bubbles clipped to bottle shape - Positioned.fill( - left: leftOffset, - bottom: bottomOffset, - child: Center( - child: SizedBox( - width: fillableWidth, - height: fillableHeight, - child: ClipPath( - clipper: BottleShapeClipper(), - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - // Animated wave - Positioned( - child: WaterConsumptionProgressWidget( - progress: progressPercent, - size: math.min(fillableWidth, fillableHeight), - containerWidth: fillableWidth, - containerHeight: fillableHeight, - waveDuration: const Duration(milliseconds: 3000), - waveColor: AppColors.blueColor, - ), - ), - - // Bubbles (only show if progress > 10%) - if (progressPercent > 10) - Positioned( - bottom: fillableHeight * 0.12, - child: Utils.buildSvgWithAssets( - icon: AppAssets.waterBottleOuterBubbles, - // Cap bubble size based on device type - height: isTablet ? math.min(45.0, fillableHeight * 0.2) : math.min(55.0, fillableHeight * 0.22), - width: fillableWidth * 0.65, - ), - ), - ], - ), - ), - ), - ), - ), - ], + Widget _buildLoadingShimmer({bool isForHistory = true}) { + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.all(0.w), + itemCount: 4, + separatorBuilder: (_, __) => SizedBox(height: 12.h), + itemBuilder: (context, index) { + return Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + height: isForHistory ? 60.h : 40.h, + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(10.r), + ), ), ); }, ); } - _buildBottomActionWidgets() { - return Consumer(builder: (context, vm, _) { - final cupAmount = vm.selectedCupCapacityMl; - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - InkWell( - onTap: () async { - // Undo last activity via API - if (cupAmount > 0) { - await vm.undoUserActivity(); - } - }, - child: Utils.buildSvgWithAssets( - icon: AppAssets.minimizeIcon, - height: 20.w, - width: 20.w, - iconColor: AppColors.textColor, - ), - ), - Container( - margin: EdgeInsets.symmetric(horizontal: 4.w), - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.blueColor, - borderRadius: 99.r, - hasShadow: true, - ), - child: (cupAmount > 0 ? "+ $cupAmount ml" : "+ 0ml").toText12( - fontWeight: FontWeight.w600, - color: AppColors.whiteColor, - ), - ), - InkWell( - onTap: () async { - // Insert user activity via API - if (cupAmount > 0) { - await vm.insertUserActivity(quantityIntake: cupAmount); - } - }, - child: Utils.buildSvgWithAssets( - icon: AppAssets.addIconDark, - ), - ), - ], - ), - SizedBox(height: 8.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildActionButton( - onTap: () => showSwitchCupBottomSheet(context), - overlayWidget: AppAssets.refreshIcon, - title: "Switch Cup".needTranslation, - icon: Utils.buildSvgWithAssets(icon: AppAssets.glassIcon, height: 24.w, width: 24.w), - ), - _buildActionButton( - onTap: () {}, - title: "Add Water".needTranslation, - icon: Utils.buildSvgWithAssets(icon: AppAssets.glassIcon, height: 24.w, width: 24.w), - ), - _buildActionButton( - onTap: () => context.navigateWithName(AppRoutes.waterMonitorSettingsScreen), - title: "Settings".needTranslation, - icon: Icon( - Icons.settings, - color: AppColors.blueColor, - size: 24.w, - ), - ), - ], - ), - ], - ); - }); - } - - _buildActionButton({String? overlayWidget, required String title, required Widget icon, required VoidCallback onTap}) { - return InkWell( - onTap: onTap, - child: Column( - children: [ - Stack( - children: [ - Container( - height: 46.w, - width: 46.w, - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.blueColor.withValues(alpha: 0.14), - borderRadius: 12.r, - hasShadow: true, - ), - child: Center(child: icon), - ), - if (overlayWidget != null) ...[ - Positioned( - top: 0, - right: 0, - child: Container( - padding: EdgeInsets.all(2.w), - height: 16.w, - width: 16.w, - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.blueColor, - borderRadius: 100.r, - hasShadow: true, - ), - child: Center(child: Utils.buildSvgWithAssets(icon: AppAssets.refreshIcon, iconColor: AppColors.whiteColor)), - ), - ), - ] - ], - ), - SizedBox(height: 4.h), - title.toText10(), - ], - ), - ); - } - Widget buildHistoryListTile({required String title, required String subTitle}) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -452,44 +132,14 @@ class _WaterConsumptionScreenState extends State { ), ], ), - if (!viewModel.isGraphView) - _buildHistoryListView(viewModel) - else - Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 32.h), - child: "Graph view coming soon".toText14(color: AppColors.greyTextColor), - ), - ), + SizedBox(height: 12.h), + if (!viewModel.isGraphView) _buildHistoryListView(viewModel) else _buildHistoryFlowchart() ], ); }), ); } - Widget _buildLoadingShimmer({bool isForHistory = true}) { - return ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.all(0.w), - itemCount: 4, - separatorBuilder: (_, __) => SizedBox(height: 12.h), - itemBuilder: (context, index) { - return Shimmer.fromColors( - baseColor: Colors.grey[300]!, - highlightColor: Colors.grey[100]!, - child: Container( - height: isForHistory ? 60.h : 40.h, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10.r), - ), - ), - ); - }, - ); - } - Widget _buildHistoryListView(WaterMonitorViewModel viewModel) { final selectedDuration = viewModel.selectedDurationFilter; @@ -524,14 +174,19 @@ class _WaterConsumptionScreenState extends State { } } else if (selectedDuration == 'Weekly') { if (viewModel.weekProgressList.isNotEmpty) { - for (var dayData in viewModel.weekProgressList) { + // Show today + last 6 days (total 7 days) + final totalDays = viewModel.weekProgressList.length; + final startIndex = totalDays > 7 ? totalDays - 7 : 0; + final weekDataToShow = viewModel.weekProgressList.skip(startIndex).toList(); + + for (var dayData in weekDataToShow) { listItems.add( buildHistoryListTile( title: dayData.dayName ?? 'Unknown', subTitle: "${dayData.percentageConsumed?.toStringAsFixed(1) ?? '0'}%", ), ); - if (dayData != viewModel.weekProgressList.last) { + if (dayData != weekDataToShow.last) { listItems.add(Divider(height: 1, color: AppColors.dividerColor)); } } @@ -547,14 +202,20 @@ class _WaterConsumptionScreenState extends State { } } else if (selectedDuration == 'Monthly') { if (viewModel.monthProgressList.isNotEmpty) { - for (var monthData in viewModel.monthProgressList) { + // Show current month + last 6 months (total 7 months) + // Reverse order to show oldest to newest (top to bottom) + final totalMonths = viewModel.monthProgressList.length; + final startIndex = totalMonths > 7 ? totalMonths - 7 : 0; + final monthDataToShow = viewModel.monthProgressList.skip(startIndex).toList().reversed.toList(); + + for (var monthData in monthDataToShow) { listItems.add( buildHistoryListTile( title: monthData.monthName ?? 'Unknown', subTitle: "${monthData.percentageConsumed?.toStringAsFixed(1) ?? '0'}%", ), ); - if (monthData != viewModel.monthProgressList.last) { + if (monthData != monthDataToShow.last) { listItems.add(Divider(height: 1, color: AppColors.dividerColor)); } } @@ -572,10 +233,7 @@ class _WaterConsumptionScreenState extends State { // Return scrollable list with min and max height constraints return ConstrainedBox( - constraints: BoxConstraints( - minHeight: 80.h, - maxHeight: 270.h, - ), + constraints: BoxConstraints(minHeight: 80.h, maxHeight: 270.h), child: viewModel.isLoading ? _buildLoadingShimmer().paddingOnly(top: 16.h) : listItems.isEmpty @@ -592,6 +250,197 @@ class _WaterConsumptionScreenState extends State { ); } + Widget _buildHistoryFlowchart() { + return Consumer( + builder: (context, viewModel, _) { + final selectedDuration = viewModel.selectedDurationFilter; + + // Build dynamic data points based on selected duration + List dataPoints = []; + + if (selectedDuration == 'Daily') { + // For daily, we show a single bar/point with today's percentage + if (viewModel.todayProgressList.isNotEmpty) { + final todayData = viewModel.todayProgressList.first; + final percentage = todayData.percentageConsumed?.toDouble() ?? 0.0; + dataPoints.add( + DataPoint( + value: percentage, + actualValue: percentage.toStringAsFixed(1), + label: 'Today', + displayTime: 'Today', + unitOfMeasurement: '%', + time: DateTime.now(), + ), + ); + } + } else if (selectedDuration == 'Weekly') { + // For weekly, show today + last 6 days (total 7 days) + if (viewModel.weekProgressList.isNotEmpty) { + final totalDays = viewModel.weekProgressList.length; + final startIndex = totalDays > 7 ? totalDays - 7 : 0; + final weekDataToShow = viewModel.weekProgressList.skip(startIndex).toList(); + + for (var dayData in weekDataToShow) { + final percentage = dayData.percentageConsumed?.toDouble() ?? 0.0; + final dayName = dayData.dayName ?? 'Day ${dayData.dayNumber}'; + dataPoints.add( + DataPoint( + value: percentage, + actualValue: percentage.toStringAsFixed(1), + label: DateUtil.getShortWeekDayName(dayName), + displayTime: dayName, + unitOfMeasurement: '%', + time: DateTime.now(), + ), + ); + } + } + } else if (selectedDuration == 'Monthly') { + // For monthly, show current month + last 6 months (total 7 months) + // Reverse order to show oldest to newest (left to right) + if (viewModel.monthProgressList.isNotEmpty) { + final totalMonths = viewModel.monthProgressList.length; + final startIndex = totalMonths > 7 ? totalMonths - 7 : 0; + final monthDataToShow = viewModel.monthProgressList.skip(startIndex).toList().reversed.toList(); + + for (var monthData in monthDataToShow) { + final percentage = monthData.percentageConsumed?.toDouble() ?? 0.0; + final monthName = monthData.monthName ?? 'Month ${monthData.monthNumber}'; + dataPoints.add( + DataPoint( + value: percentage, + actualValue: percentage.toStringAsFixed(1), + label: DateUtil.getShortMonthName(monthName), + displayTime: monthName, + unitOfMeasurement: '%', + time: DateTime.now(), + ), + ); + } + } + } + + // If no data, show empty state + if (dataPoints.isEmpty) { + return Container( + padding: EdgeInsets.symmetric(vertical: 80.h), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bar_chart, size: 48.w, color: AppColors.greyTextColor.withValues(alpha: 0.5)), + SizedBox(height: 12.h), + "No chart data available".toText14(color: AppColors.greyTextColor), + ], + ), + ), + ); + } + + // Show loading shimmer while fetching data + if (viewModel.isLoading) { + return Container( + padding: EdgeInsets.symmetric(vertical: 40.h), + child: _buildLoadingShimmer(), + ); + } + + // Define ranges for percentage (0-100%) + const double low = 25.0; // Below 25% is low + const double medium = 50.0; // 25-50% is medium + const double good = 75.0; // 50-75% is good + const double maxY = 100.0; // Max is 100% + + return CustomGraph( + dataPoints: dataPoints, + makeGraphBasedOnActualValue: true, + leftLabelReservedSize: 50.w, + leftLabelInterval: 25, + showGridLines: true, + maxY: maxY, + minY: 0, + maxX: dataPoints.length > 1 ? dataPoints.length.toDouble() - 0.75 : 1.0, + minX: -0.2, + horizontalInterval: 25, + // Grid lines every 25% + showShadow: true, + getDrawingHorizontalLine: (value) { + // Draw dashed lines at 25%, 50%, 75% + if (value == low || value == medium || value == good) { + return FlLine( + color: AppColors.greyTextColor.withValues(alpha: 0.3), + strokeWidth: 1.5, + dashArray: [8, 4], + ); + } + return FlLine(color: AppColors.transparent, strokeWidth: 0); + }, + leftLabelFormatter: (value) { + // Show percentage labels at key points + if (value == 0) return '0%'.toText10(weight: FontWeight.w600); + if (value == 25) return '25%'.toText10(weight: FontWeight.w600); + if (value == 50) return '50%'.toText10(weight: FontWeight.w600); + if (value == 75) return '75%'.toText10(weight: FontWeight.w600); + if (value == 100) return '100%'.toText10(weight: FontWeight.w600); + return SizedBox.shrink(); + }, + graphColor: AppColors.successColor, + graphShadowColor: AppColors.successColor.withValues(alpha: 0.15), + bottomLabelFormatter: (value, data) { + if (data.isEmpty) return SizedBox.shrink(); + + // Only show labels for whole number positions (not fractional) + if ((value - value.round()).abs() > 0.01) { + return SizedBox.shrink(); + } + + int index = value.round(); + if (index < 0 || index >= data.length) return SizedBox.shrink(); + + // For daily, show only index 0 + if (selectedDuration == 'Daily' && index == 0) { + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: data[index].label.toText10( + weight: FontWeight.w600, + color: AppColors.labelTextColor, + ), + ); + } + + // For weekly, show all 7 days (today + last 6 days) + if (selectedDuration == 'Weekly' && index < 7) { + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: data[index].label.toText10( + weight: FontWeight.w600, + color: AppColors.labelTextColor, + ), + ); + } + + // For monthly, show all 7 months (current month + last 6 months) + if (selectedDuration == 'Monthly' && index < 7) { + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: data[index].label.toText10( + weight: FontWeight.w600, + color: AppColors.labelTextColor, + ), + ); + } + + return SizedBox.shrink(); + }, + scrollDirection: selectedDuration == 'Monthly' ? Axis.horizontal : Axis.vertical, + height: 250.h, + spotColor: AppColors.successColor, + ); + }, + ); + } + // Reusable method to build selection row widget Widget _buildSelectionRow({ required String value, @@ -634,7 +483,7 @@ class _WaterConsumptionScreenState extends State { message: "", child: Container( padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 4.h, bottom: 4.h), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20.r)), + decoration: BoxDecoration(color: AppColors.whiteColor, borderRadius: BorderRadius.circular(20.r)), child: ListView.separated( shrinkWrap: true, itemCount: items.length, @@ -667,43 +516,162 @@ class _WaterConsumptionScreenState extends State { ); } + /// Handle reminder button tap (Set or Cancel) + Future _handleReminderButtonTap(WaterMonitorViewModel viewModel) async { + if (viewModel.isWaterReminderEnabled) { + // Cancel reminders + _showCancelReminderConfirmation(viewModel); + } else { + // Set reminders + await _setReminders(viewModel); + } + } + + /// Show confirmation bottom sheet before cancelling reminders + void _showCancelReminderConfirmation(WaterMonitorViewModel viewModel) { + showCommonBottomSheetWithoutHeight( + title: 'Notice'.needTranslation, + context, + child: Utils.getWarningWidget( + loadingText: "Are you sure you want to cancel all water reminders?".needTranslation, + isShowActionButtons: true, + onCancelTap: () { + Navigator.pop(context); + }, + onConfirmTap: () async { + Navigator.pop(context); + await _cancelReminders(viewModel); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + isDismissible: true, + ); + } + + /// Set water reminders + Future _setReminders(WaterMonitorViewModel viewModel) async { + // Schedule reminders + final success = await viewModel.scheduleWaterReminders(); + + if (success) { + final times = await viewModel.getScheduledReminderTimes(); + _showReminderScheduledDialog(times); + } + } + + /// Cancel water reminders + Future _cancelReminders(WaterMonitorViewModel viewModel) async { + final success = await viewModel.cancelWaterReminders(); + } + + /// Show bottom sheet with scheduled reminder times + void _showReminderScheduledDialog(List times) { + showCommonBottomSheetWithoutHeight( + title: 'Reminders Set!'.needTranslation, + context, + isCloseButtonVisible: false, + isDismissible: false, + child: Padding( + padding: EdgeInsets.only(top: 16.w, left: 16.w, right: 16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Utils.getSuccessWidget(loadingText: 'Daily water reminders scheduled at:'.needTranslation), + SizedBox(height: 16.h), + Wrap( + spacing: 8.w, + runSpacing: 8.h, + children: times + .map( + (time) => AppCustomChipWidget( + icon: AppAssets.bell, + iconColor: AppColors.quickLoginColor, + richText: _formatTime(time).toText14(), + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h), + ), + ) + .toList(), + ), + + SizedBox(height: 24.h), + + // OK button + Row( + children: [ + Expanded( + child: CustomButton( + height: 56.h, + text: 'OK'.needTranslation, + onPressed: () => Navigator.of(context).pop(), + textColor: AppColors.whiteColor, + ), + ), + ], + ), + ], + ), + ), + callBackFunc: () {}, + ); + } + + /// Format DateTime to readable time string + String _formatTime(DateTime time) { + final hour = time.hour; + final minute = time.minute; + final hour12 = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour); + final period = hour >= 12 ? 'PM' : 'AM'; + return '${hour12.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')} $period'; + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.bgScaffoldColor, body: CollapsingListView( title: "Water Consumption".needTranslation, - bottomChild: Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 24.r, - hasShadow: true, - ), - child: Padding( - padding: EdgeInsets.all(24.w), - child: CustomButton( - text: "Set Reminder".needTranslation, - textColor: AppColors.successColor, - backgroundColor: AppColors.successLightBgColor, - onPressed: () {}, - icon: AppAssets.bell, - iconColor: AppColors.successColor, - borderRadius: 12.r, - borderColor: AppColors.transparent, - padding: EdgeInsets.symmetric(vertical: 14.h), - ), - ), + bottomChild: Consumer( + builder: (context, viewModel, child) { + return Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.r, + hasShadow: true, + ), + child: Padding( + padding: EdgeInsets.all(24.w), + child: CustomButton( + text: viewModel.isWaterReminderEnabled ? "Cancel Reminders".needTranslation : "Set Reminder".needTranslation, + textColor: viewModel.isWaterReminderEnabled ? AppColors.errorColor : AppColors.successColor, + backgroundColor: viewModel.isWaterReminderEnabled ? AppColors.errorColor.withValues(alpha: 0.1) : AppColors.successLightBgColor, + onPressed: () => _handleReminderButtonTap(viewModel), + icon: viewModel.isWaterReminderEnabled ? null : AppAssets.bell, + iconColor: viewModel.isWaterReminderEnabled ? AppColors.errorColor : AppColors.successColor, + borderRadius: 12.r, + borderColor: AppColors.transparent, + padding: EdgeInsets.symmetric(vertical: 14.h), + ), + ), + ); + }, ), - child: Column( - children: [ - SizedBox(height: 16.h), - _buildWaterIntakeSummaryWidget(), - SizedBox(height: 16.h), - _buildHistoryGraphOrList(), - SizedBox(height: 16.h), - _buildHydrationTipsWidget(), - SizedBox(height: 16.h), - ], + child: RefreshIndicator( + onRefresh: _refreshData, + color: AppColors.blueColor, + backgroundColor: AppColors.whiteColor, + child: Column( + children: [ + SizedBox(height: 16.h), + const WaterIntakeSummaryWidget(), + SizedBox(height: 16.h), + _buildHistoryGraphOrList(), + SizedBox(height: 16.h), + const HydrationTipsWidget(), + SizedBox(height: 16.h), + ], + ), ), ), ); diff --git a/lib/presentation/water_monitor/water_monitor_settings_screen.dart b/lib/presentation/water_monitor/water_monitor_settings_screen.dart index 5f1bce5..45a39e6 100644 --- a/lib/presentation/water_monitor/water_monitor_settings_screen.dart +++ b/lib/presentation/water_monitor/water_monitor_settings_screen.dart @@ -12,10 +12,23 @@ import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart'; import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; import 'package:provider/provider.dart'; -class WaterMonitorSettingsScreen extends StatelessWidget { +class WaterMonitorSettingsScreen extends StatefulWidget { const WaterMonitorSettingsScreen({super.key}); - void _showSnackbar(String text, BuildContext context) { + @override + State createState() => _WaterMonitorSettingsScreenState(); +} + +class _WaterMonitorSettingsScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initialize(); + }); + } + + void _showSnackbar(String text) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(text), @@ -266,9 +279,7 @@ class WaterMonitorSettingsScreen extends StatelessWidget { if (!success && viewModel.validationError != null) { _showSnackBar(context, viewModel.validationError!); } else if (success) { - _showSnackBar(context, "Settings saved successfully".needTranslation); - // Navigate back on success - Navigator.pop(context); + _showSnackBar(context, "Settings saved successfully"); } }, borderRadius: 12.r, @@ -339,7 +350,9 @@ class WaterMonitorSettingsScreen extends StatelessWidget { content: Text(message), duration: const Duration(seconds: 3), behavior: SnackBarBehavior.floating, - backgroundColor: message.contains('successfully') ? Colors.green : AppColors.errorColor, + backgroundColor: message.contains('successfully') + ? Colors.green + : AppColors.errorColor, ), ); } diff --git a/lib/presentation/water_monitor/widgets/hydration_tips_widget.dart b/lib/presentation/water_monitor/widgets/hydration_tips_widget.dart new file mode 100644 index 0000000..df55886 --- /dev/null +++ b/lib/presentation/water_monitor/widgets/hydration_tips_widget.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/app_assets.dart'; +import 'package:hmg_patient_app_new/core/app_export.dart'; +import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; +import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; + +class HydrationTipsWidget extends StatelessWidget { + const HydrationTipsWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.symmetric(horizontal: 24.w), + padding: EdgeInsets.all(16.h), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.r, + hasShadow: true, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Utils.buildSvgWithAssets( + icon: AppAssets.bulb_icon, + width: 24.w, + height: 24.h, + ), + SizedBox(width: 8.w), + "Tips to stay hydrated".needTranslation.toText16(isBold: true), + ], + ), + SizedBox(height: 8.h), + " • ${"Drink before you feel thirsty"}".needTranslation.toText12( + fontWeight: FontWeight.w500, + color: AppColors.textColorLight, + ), + SizedBox(height: 4.h), + " • ${"Keep a refillable bottle next to you"}".needTranslation.toText12( + fontWeight: FontWeight.w500, + color: AppColors.textColorLight, + ), + SizedBox(height: 4.h), + " • ${"Track your daily intake to stay motivated"}".needTranslation.toText12( + fontWeight: FontWeight.w500, + color: AppColors.textColorLight, + ), + SizedBox(height: 4.h), + " • ${"Choose sparkling water instead of soda"}".needTranslation.toText12( + fontWeight: FontWeight.w500, + color: AppColors.textColorLight, + ), + SizedBox(height: 8.h), + ], + ), + ); + } +} + diff --git a/lib/presentation/water_monitor/widgets/water_action_buttons_widget.dart b/lib/presentation/water_monitor/widgets/water_action_buttons_widget.dart new file mode 100644 index 0000000..510926c --- /dev/null +++ b/lib/presentation/water_monitor/widgets/water_action_buttons_widget.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/app_assets.dart'; +import 'package:hmg_patient_app_new/core/app_export.dart'; +import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/extensions/route_extensions.dart'; +import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; +import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; +import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/cup_bottomsheet_widgets.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:provider/provider.dart'; + +class WaterActionButtonsWidget extends StatelessWidget { + const WaterActionButtonsWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, vm, _) { + final cupAmount = vm.selectedCupCapacityMl; + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InkWell( + onTap: () async { + if (cupAmount > 0) { + await vm.undoUserActivity(); + } + }, + child: Utils.buildSvgWithAssets( + icon: AppAssets.minimizeIcon, + height: 20.w, + width: 20.w, + iconColor: AppColors.textColor, + ), + ), + Container( + margin: EdgeInsets.symmetric(horizontal: 4.w), + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.blueColor, + borderRadius: 99.r, + hasShadow: true, + ), + child: (cupAmount > 0 ? "+ $cupAmount ml" : "+ 0ml").toText12( + fontWeight: FontWeight.w600, + color: AppColors.whiteColor, + ), + ), + InkWell( + onTap: () async { + if (cupAmount > 0) { + await vm.insertUserActivity(quantityIntake: cupAmount); + } + }, + child: Utils.buildSvgWithAssets( + icon: AppAssets.addIconDark, + ), + ), + ], + ), + SizedBox(height: 8.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildActionButton( + context: context, + onTap: () => showSwitchCupBottomSheet(context), + overlayWidget: AppAssets.refreshIcon, + title: "Switch Cup".needTranslation, + icon: Utils.buildSvgWithAssets(icon: AppAssets.glassIcon, height: 24.w, width: 24.w), + ), + _buildActionButton( + context: context, + onTap: () async { + final success = await vm.scheduleTestNotification(); + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Test notification will appear in 5 seconds!'.needTranslation), + backgroundColor: AppColors.blueColor, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(16.w), + duration: const Duration(seconds: 2), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to schedule test notification'.needTranslation), + backgroundColor: AppColors.errorColor, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(16.w), + ), + ); + } + }, + title: "Test Alert".needTranslation, + icon: Icon( + Icons.notifications_outlined, + color: AppColors.blueColor, + size: 24.w, + ), + ), + _buildActionButton( + context: context, + onTap: () => context.navigateWithName(AppRoutes.waterMonitorSettingsScreen), + title: "Settings".needTranslation, + icon: Icon( + Icons.settings, + color: AppColors.blueColor, + size: 24.w, + ), + ), + ], + ), + ], + ); + }); + } + + Widget _buildActionButton({ + required BuildContext context, + String? overlayWidget, + required String title, + required Widget icon, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Column( + children: [ + Stack( + children: [ + Container( + height: 46.w, + width: 46.w, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.blueColor.withValues(alpha: 0.14), + borderRadius: 12.r, + hasShadow: true, + ), + child: Center(child: icon), + ), + if (overlayWidget != null) ...[ + Positioned( + top: 0, + right: 0, + child: Container( + padding: EdgeInsets.all(2.w), + height: 16.w, + width: 16.w, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.blueColor, + borderRadius: 100.r, + hasShadow: true, + ), + child: Center( + child: Utils.buildSvgWithAssets( + icon: AppAssets.refreshIcon, + iconColor: AppColors.whiteColor, + ), + ), + ), + ), + ] + ], + ), + SizedBox(height: 4.h), + title.toText10(), + ], + ), + ); + } +} diff --git a/lib/presentation/water_monitor/widgets/water_bottle_widget.dart b/lib/presentation/water_monitor/widgets/water_bottle_widget.dart new file mode 100644 index 0000000..b681e32 --- /dev/null +++ b/lib/presentation/water_monitor/widgets/water_bottle_widget.dart @@ -0,0 +1,112 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/app_assets.dart'; +import 'package:hmg_patient_app_new/core/app_export.dart'; +import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/bottle_shape_clipper.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/water_consumption_progress_widget.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:provider/provider.dart'; + +class WaterBottleWidget extends StatelessWidget { + const WaterBottleWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, vm, _) { + final progressPercent = (vm.progress * 100).clamp(0.0, 100.0); + + // SVG aspect ratio + const svgAspectRatio = 315.0 / 143.0; // ~2.2 + + // Responsive bottle sizing with device-specific constraints + double bottleWidth; + if (isTablet) { + bottleWidth = math.min(SizeUtils.width * 0.15, 180.0); + } else if (isFoldable) { + bottleWidth = math.min(100.w, 160.0); + } else { + bottleWidth = math.min(120.w, 140.0); + } + + final bottleHeight = bottleWidth * svgAspectRatio; + + // Fillable area percentages + final fillableHeightPercent = 0.7; + const fillableWidthPercent = 0.8; + + final fillableHeight = bottleHeight * fillableHeightPercent; + final fillableWidth = bottleWidth * fillableWidthPercent; + + // Device-specific positioning offsets + final double leftOffset = isTablet ? 4.w : 8.w; + final double bottomOffset = isTablet ? -65.h : -78.h; + + return SizedBox( + height: bottleHeight, + width: bottleWidth, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + // Bottle SVG outline + Center( + child: Utils.buildSvgWithAssets( + icon: AppAssets.waterBottle, + height: bottleHeight, + width: bottleWidth, + fit: BoxFit.contain, + ), + ), + + // Wave and bubbles clipped to bottle shape + Positioned.fill( + left: leftOffset, + bottom: bottomOffset, + child: Center( + child: SizedBox( + width: fillableWidth, + height: fillableHeight, + child: ClipPath( + clipper: BottleShapeClipper(), + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + // Animated wave + Positioned( + child: WaterConsumptionProgressWidget( + progress: progressPercent, + size: math.min(fillableWidth, fillableHeight), + containerWidth: fillableWidth, + containerHeight: fillableHeight, + waveDuration: const Duration(milliseconds: 3000), + waveColor: AppColors.blueColor, + ), + ), + + // Bubbles (only show if progress > 10%) + if (progressPercent > 10) + Positioned( + bottom: fillableHeight * 0.12, + child: Utils.buildSvgWithAssets( + icon: AppAssets.waterBottleOuterBubbles, + height: isTablet ? math.min(45.0, fillableHeight * 0.2) : math.min(55.0, fillableHeight * 0.22), + width: fillableWidth * 0.65, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/presentation/water_monitor/widgets/water_consumption_progress_widget.dart b/lib/presentation/water_monitor/widgets/water_consumption_progress_widget.dart index 993d7ce..08350a1 100644 --- a/lib/presentation/water_monitor/widgets/water_consumption_progress_widget.dart +++ b/lib/presentation/water_monitor/widgets/water_consumption_progress_widget.dart @@ -11,15 +11,7 @@ class WaterConsumptionProgressWidget extends StatefulWidget { final double? containerHeight; final Duration? waveDuration; - const WaterConsumptionProgressWidget({ - super.key, - required this.progress, - this.size = 100, - this.waveColor, - this.containerWidth, - this.containerHeight, - this.waveDuration, - }); + const WaterConsumptionProgressWidget({super.key, required this.progress, this.size = 100, this.waveColor, this.containerWidth, this.containerHeight, this.waveDuration}); @override State createState() => _WaterConsumptionProgressWidgetState(); diff --git a/lib/presentation/water_monitor/widgets/water_intake_summary_widget.dart b/lib/presentation/water_monitor/widgets/water_intake_summary_widget.dart new file mode 100644 index 0000000..094cc38 --- /dev/null +++ b/lib/presentation/water_monitor/widgets/water_intake_summary_widget.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/app_export.dart'; +import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; +import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/water_action_buttons_widget.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/water_bottle_widget.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:provider/provider.dart'; +import 'package:shimmer/shimmer.dart'; + +class WaterIntakeSummaryWidget extends StatelessWidget { + const WaterIntakeSummaryWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(24.w), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.blueGradientColorOne, + AppColors.blueGradientColorTwo, + ], + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: isTablet ? 2 : 3, + child: Consumer(builder: (context, vm, _) { + if (vm.isLoading) { + return _buildLoadingShimmer(); + } + + final goalMl = vm.dailyGoalMl; + final consumed = vm.totalConsumedMl; + final remaining = (goalMl - consumed) > 0 ? (goalMl - consumed) : 0; + final completedPercent = "${(vm.progress * 100).clamp(0.0, 100.0).toStringAsFixed(0)}%"; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + "Next Drink Time".needTranslation.toText18(weight: FontWeight.w600, color: AppColors.textColor), + vm.nextDrinkTime.toText32(weight: FontWeight.w600, color: AppColors.blueColor), + SizedBox(height: 12.h), + _buildStatusColumn(title: "Your Goal".needTranslation, subTitle: "${goalMl}ml"), + SizedBox(height: 8.h), + _buildStatusColumn(title: "Remaining".needTranslation, subTitle: "${remaining}ml"), + SizedBox(height: 8.h), + _buildStatusColumn(title: "Completed".needTranslation, subTitle: completedPercent, subTitleColor: AppColors.successColor), + SizedBox(height: 8.h), + _buildStatusColumn( + title: "Hydration Status".needTranslation, + subTitle: vm.hydrationStatus, + subTitleColor: vm.hydrationStatusColor, + ), + ], + ); + }), + ), + SizedBox(width: isTablet ? 32 : 16.w), + Expanded( + flex: isTablet ? 1 : 2, + child: const WaterBottleWidget(), + ), + ], + ), + const WaterActionButtonsWidget(), + ], + ), + ); + } + + Widget _buildStatusColumn({required String title, required String subTitle, Color? subTitleColor}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + "$title: ".toText16(weight: FontWeight.w500, color: AppColors.textColor), + subTitle.toText12( + fontWeight: FontWeight.w600, + color: subTitleColor ?? AppColors.greyTextColor, + ), + ], + ); + } + + Widget _buildLoadingShimmer() { + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.all(0.w), + itemCount: 4, + separatorBuilder: (_, __) => SizedBox(height: 12.h), + itemBuilder: (context, index) { + return Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + height: 40.h, + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(10.r), + ), + ), + ); + }, + ); + } +} diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index f4b9954..9b045dc 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -85,11 +85,8 @@ class AppRoutes { userInfoFlowManager: (context) => UserInfoFlowManager(), smartWatches: (context) => SmartwatchInstructionsPage(), huaweiHealthExample: (context) => HuaweiHealthExample(), - healthCalculatorsPage: (context) => HealthCalculatorsPage(), waterConsumptionScreen: (context) => WaterConsumptionScreen(), waterMonitorSettingsScreen: (context) => WaterMonitorSettingsScreen(), - - // healthCalculatorsPage: (context) => HealthCalculatorsPage(type: HealthCalConEnum.calculator), healthConvertersPage: (context) => HealthCalculatorsPage(type: HealthCalConEnum.converter) }; diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..fd154c6 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,373 @@ +import 'dart:typed_data'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:hmg_patient_app_new/services/logger_service.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'package:timezone/timezone.dart' as tz show TZDateTime, local, setLocalLocation, getLocation; + +/// Abstract class defining the notification service interface +abstract class NotificationService { + /// Initialize the notification service + Future initialize({Function(String payload)? onNotificationClick}); + + /// Request notification permissions (mainly for iOS) + Future requestPermissions(); + + /// Show an immediate notification + Future showNotification({ + required String title, + required String body, + String? payload, + }); + + /// Schedule a notification at a specific date and time + Future scheduleNotification({ + required int id, + required String title, + required String body, + required DateTime scheduledDate, + String? payload, + }); + + /// Schedule daily notifications at specific times + Future scheduleDailyNotifications({ + required List times, + required String title, + required String body, + String? payload, + }); + + /// Schedule water reminder notifications + Future scheduleWaterReminders({ + required List reminderTimes, + required String title, + required String body, + }); + + /// Cancel a specific notification by id + Future cancelNotification(int id); + + /// Cancel all scheduled notifications + Future cancelAllNotifications(); + + /// Get list of pending notifications + Future> getPendingNotifications(); +} + +/// Implementation of NotificationService following the project architecture +class NotificationServiceImp implements NotificationService { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; + final LoggerService loggerService; + + NotificationServiceImp({required this.flutterLocalNotificationsPlugin, required this.loggerService}); + + // Channel IDs for different notification types + static const String _waterReminderChannelId = 'water_reminder_channel'; + static const String _waterReminderChannelName = 'Water Reminders'; + static const String _waterReminderChannelDescription = 'Daily water intake reminders'; + + static const String _generalChannelId = 'hmg_general_channel'; + static const String _generalChannelName = 'HMG Notifications'; + static const String _generalChannelDescription = 'General notifications from HMG'; + + Function(String payload)? _onNotificationClick; + + @override + Future initialize({Function(String payload)? onNotificationClick}) async { + try { + // Initialize timezone database + tz.initializeTimeZones(); + + // Set local timezone (you can also use a specific timezone if needed) + // For example: tz.setLocalLocation(tz.getLocation('Asia/Riyadh')); + final locationName = DateTime.now().timeZoneName; + try { + tz.setLocalLocation(tz.getLocation(locationName)); + } catch (e) { + // Fallback to UTC if specific timezone not found + loggerService.logInfo('Could not set timezone $locationName, using UTC'); + tz.setLocalLocation(tz.getLocation('UTC')); + } + + _onNotificationClick = onNotificationClick; + + const androidSettings = AndroidInitializationSettings('app_icon'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + + const initializationSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + await flutterLocalNotificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: _handleNotificationResponse, + ); + + loggerService.logInfo('NotificationService initialized successfully'); + } catch (ex) { + loggerService.logError('Failed to initialize NotificationService: $ex'); + } + } + + /// Handle notification tap + void _handleNotificationResponse(NotificationResponse response) { + try { + if (response.payload != null && _onNotificationClick != null) { + _onNotificationClick!(response.payload!); + } + loggerService.logInfo('Notification tapped: ${response.payload}'); + } catch (ex) { + loggerService.logError('Error handling notification response: $ex'); + } + } + + @override + Future requestPermissions() async { + try { + // Request permissions for iOS + final result = + await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation()?.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + + // For Android 13+, permissions are requested at runtime + final androidResult = await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.requestNotificationsPermission(); + + loggerService.logInfo('Notification permissions: iOS=${result ?? true}, Android=${androidResult ?? true}'); + return result ?? androidResult ?? true; + } catch (ex) { + loggerService.logError('Error requesting notification permissions: $ex'); + return false; + } + } + + @override + Future showNotification({ + required String title, + required String body, + String? payload, + }) async { + try { + final androidDetails = AndroidNotificationDetails( + _generalChannelId, + _generalChannelName, + channelDescription: _generalChannelDescription, + importance: Importance.high, + priority: Priority.high, + vibrationPattern: _getVibrationPattern(), + ); + + const iosDetails = DarwinNotificationDetails(); + + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await flutterLocalNotificationsPlugin.show( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + title, + body, + notificationDetails, + payload: payload, + ); + + loggerService.logInfo('Notification shown: $title'); + } catch (ex) { + loggerService.logError('Error showing notification: $ex'); + } + } + + @override + Future scheduleNotification({ + required int id, + required String title, + required String body, + required DateTime scheduledDate, + String? payload, + }) async { + try { + final androidDetails = AndroidNotificationDetails( + _generalChannelId, + _generalChannelName, + channelDescription: _generalChannelDescription, + importance: Importance.high, + priority: Priority.high, + vibrationPattern: _getVibrationPattern(), + ); + + const iosDetails = DarwinNotificationDetails(); + + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await flutterLocalNotificationsPlugin.zonedSchedule( + id, + title, + body, + tz.TZDateTime.from(scheduledDate, tz.local), + notificationDetails, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + payload: payload, + ); + + loggerService.logInfo('Notification scheduled for: $scheduledDate'); + } catch (ex) { + loggerService.logError('Error scheduling notification: $ex'); + } + } + + @override + Future scheduleDailyNotifications({ + required List times, + required String title, + required String body, + String? payload, + }) async { + try { + for (int i = 0; i < times.length; i++) { + final time = times[i]; + await scheduleNotification( + id: i + 1000, + // Offset ID to avoid conflicts + title: title, + body: body, + scheduledDate: time, + payload: payload, + ); + } + + loggerService.logInfo('Scheduled ${times.length} daily notifications'); + } catch (ex) { + loggerService.logError('Error scheduling daily notifications: $ex'); + } + } + + @override + Future scheduleWaterReminders({ + required List reminderTimes, + required String title, + required String body, + }) async { + try { + // Cancel existing water reminders first + await _cancelWaterReminders(); + + final androidDetails = AndroidNotificationDetails( + _waterReminderChannelId, + _waterReminderChannelName, + channelDescription: _waterReminderChannelDescription, + importance: Importance.high, + priority: Priority.high, + vibrationPattern: _getVibrationPattern(), + icon: 'app_icon', + styleInformation: const BigTextStyleInformation(''), + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + for (int i = 0; i < reminderTimes.length; i++) { + final reminderTime = reminderTimes[i]; + final notificationId = 5000 + i; // Use 5000+ range for water reminders + + // Schedule for today if time hasn't passed, otherwise schedule for tomorrow + DateTime scheduledDate = reminderTime; + if (scheduledDate.isBefore(DateTime.now())) { + scheduledDate = scheduledDate.add(const Duration(days: 1)); + } + + await flutterLocalNotificationsPlugin.zonedSchedule( + notificationId, + title, + body, + tz.TZDateTime.from(scheduledDate, tz.local), + notificationDetails, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + matchDateTimeComponents: DateTimeComponents.time, // Repeat daily at the same time + payload: 'water_reminder_$i', + ); + } + + loggerService.logInfo('Scheduled ${reminderTimes.length} water reminders'); + } catch (ex) { + loggerService.logError('Error scheduling water reminders: $ex'); + } + } + + /// Cancel all water reminders (IDs 5000-5999) + Future _cancelWaterReminders() async { + try { + final pendingNotifications = await getPendingNotifications(); + for (final notification in pendingNotifications) { + if (notification.id >= 5000 && notification.id < 6000) { + await cancelNotification(notification.id); + } + } + loggerService.logInfo('Cancelled all water reminders'); + } catch (ex) { + loggerService.logError('Error cancelling water reminders: $ex'); + } + } + + @override + Future cancelNotification(int id) async { + try { + await flutterLocalNotificationsPlugin.cancel(id); + loggerService.logInfo('Cancelled notification with ID: $id'); + } catch (ex) { + loggerService.logError('Error cancelling notification: $ex'); + } + } + + @override + Future cancelAllNotifications() async { + try { + await flutterLocalNotificationsPlugin.cancelAll(); + loggerService.logInfo('Cancelled all notifications'); + } catch (ex) { + loggerService.logError('Error cancelling all notifications: $ex'); + } + } + + @override + Future> getPendingNotifications() async { + try { + final pending = await flutterLocalNotificationsPlugin.pendingNotificationRequests(); + loggerService.logInfo('Found ${pending.length} pending notifications'); + return pending; + } catch (ex) { + loggerService.logError('Error getting pending notifications: $ex'); + return []; + } + } + + /// Get vibration pattern for notifications + Int64List _getVibrationPattern() { + final vibrationPattern = Int64List(4); + vibrationPattern[0] = 0; + vibrationPattern[1] = 500; + vibrationPattern[2] = 500; + vibrationPattern[3] = 500; + return vibrationPattern; + } +} diff --git a/lib/splashPage.dart b/lib/splashPage.dart index bd7afa5..043eefd 100644 --- a/lib/splashPage.dart +++ b/lib/splashPage.dart @@ -5,30 +5,30 @@ import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_zoom_videosdk/native/zoom_videosdk.dart'; import 'package:get_it/get_it.dart'; -import 'package:hmg_patient_app_new/presentation/onboarding/onboarding_screen.dart'; -import 'package:hmg_patient_app_new/presentation/onboarding/splash_animation_screen.dart'; import 'package:hmg_patient_app_new/core/api_consts.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; import 'package:hmg_patient_app_new/core/app_export.dart'; +import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; import 'package:hmg_patient_app_new/features/authentication/authentication_view_model.dart'; - -// import 'package:hmg_patient_app_new/presentation/authantication/login.dart'; import 'package:hmg_patient_app_new/presentation/home/navigation_screen.dart'; +import 'package:hmg_patient_app_new/presentation/onboarding/onboarding_screen.dart'; +import 'package:hmg_patient_app_new/presentation/onboarding/splash_animation_screen.dart'; import 'package:hmg_patient_app_new/presentation/tele_consultation/zoom/call_screen.dart'; import 'package:hmg_patient_app_new/services/cache_service.dart'; import 'package:hmg_patient_app_new/services/navigation_service.dart'; +import 'package:hmg_patient_app_new/services/notification_service.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/widgets/transitions/fade_page.dart'; import 'package:lottie/lottie.dart'; -import 'package:provider/provider.dart'; import 'core/cache_consts.dart'; -import 'core/utils/local_notifications.dart'; import 'core/utils/push_notification_handler.dart'; class SplashPage extends StatefulWidget { + const SplashPage({super.key}); + @override _SplashScreenState createState() => _SplashScreenState(); } @@ -47,9 +47,13 @@ class _SplashScreenState extends State { ); await authVm.getServicePrivilege(); Timer(Duration(seconds: 2, milliseconds: 500), () async { - bool isAppOpenedFromCall = await GetIt.instance().getBool(key: CacheConst.isAppOpenedFromCall) ?? false; + bool isAppOpenedFromCall = getIt.get().getBool(key: CacheConst.isAppOpenedFromCall) ?? false; - LocalNotification.init(onNotificationClick: (payload) {}); + // Initialize NotificationService using dependency injection + final notificationService = getIt.get(); + await notificationService.initialize(onNotificationClick: (payload) { + // Handle notification click here + }); if (isAppOpenedFromCall) { navigateToTeleConsult(); @@ -78,7 +82,8 @@ class _SplashScreenState extends State { // GetIt.instance().remove(key: CacheConst.isAppOpenedFromCall); Utils.removeFromPrefs(CacheConst.isAppOpenedFromCall); - Navigator.of(GetIt.instance().navigatorKey.currentContext!).pushReplacement(FadePage(page: SplashAnimationScreen(routeWidget: LandingNavigation()))); + Navigator.of(GetIt.instance().navigatorKey.currentContext!) + .pushReplacement(FadePage(page: SplashAnimationScreen(routeWidget: LandingNavigation()))); Navigator.pushReplacementNamed( // context, GetIt.instance().navigatorKey.currentContext!, @@ -216,7 +221,7 @@ class _SplashScreenState extends State { @override void initState() { - authVm = context.read(); + authVm = getIt(); super.initState(); initializeStuff(); } @@ -225,6 +230,8 @@ class _SplashScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.whiteColor, - body: Lottie.asset(AppAnimations.loadingAnimation, repeat: true, reverse: false, frameRate: FrameRate(60), width: 80.h, height: 80.h, fit: BoxFit.fill).center); + body: Lottie.asset(AppAnimations.loadingAnimation, + repeat: true, reverse: false, frameRate: FrameRate(60), width: 80.h, height: 80.h, fit: BoxFit.fill) + .center); } } diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index 4494ce4..6dfb207 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -104,4 +104,8 @@ class AppColors { static const Color blueColor = Color(0xFF4EB5FF); static const Color blueGradientColorOne = Color(0xFFF1F7FD); static const Color blueGradientColorTwo = Color(0xFFD9EFFF); + + // Shimmer + static const Color shimmerBaseColor = Color(0xFFE0E0E0); + static const Color shimmerHighlightColor = Color(0xFFF5F5F5); } diff --git a/lib/widgets/chip/app_custom_chip_widget.dart b/lib/widgets/chip/app_custom_chip_widget.dart index 8be16b7..69d82b7 100644 --- a/lib/widgets/chip/app_custom_chip_widget.dart +++ b/lib/widgets/chip/app_custom_chip_widget.dart @@ -72,11 +72,11 @@ class AppCustomChipWidget extends StatelessWidget { ? Image.asset(icon, width: iconS, height: iconS) : Utils.buildSvgWithAssets( icon: icon, - width: iconS, - height: iconS, - iconColor: iconHasColor ? iconColor : null, - fit: BoxFit.contain, - ) + width: iconS, + height: iconS, + iconColor: iconHasColor ? iconColor : null, + fit: BoxFit.contain, + ) : SizedBox.shrink(), label: richText ?? labelText!.toText10(weight: FontWeight.w500, letterSpacing: 0, color: textColor), padding: padding, diff --git a/lib/widgets/graph/custom_graph.dart b/lib/widgets/graph/custom_graph.dart index b955b32..e04d978 100644 --- a/lib/widgets/graph/custom_graph.dart +++ b/lib/widgets/graph/custom_graph.dart @@ -1,8 +1,9 @@ -import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; import 'package:hmg_patient_app_new/core/common_models/data_points.dart'; import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; + /// A customizable line graph widget using `fl_chart`. /// /// Displays a line chart with configurable axis labels, colors, and data points. @@ -56,67 +57,69 @@ class CustomGraph extends StatelessWidget { final FontWeight? bottomLabelFontWeight; final double? leftLabelInterval; final double? leftLabelReservedSize; + final double? bottomLabelReservedSize; final bool? showGridLines; final GetDrawingGridLine? getDrawingHorizontalLine; final double? horizontalInterval; final double? minY; final bool showShadow; + final double? cutOffY; final RangeAnnotations? rangeAnnotations; ///creates the left label and provide it to the chart as it will be used by other part of the application so the label will be different for every chart final Widget Function(double) leftLabelFormatter; - final Widget Function(double , List) bottomLabelFormatter; - + final Widget Function(double, List) bottomLabelFormatter; final Axis scrollDirection; final bool showBottomTitleDates; final bool isFullScreeGraph; final bool makeGraphBasedOnActualValue; - const CustomGraph({ - super.key, - required this.dataPoints, - required this.leftLabelFormatter, - this.width, - required this.scrollDirection, - required this.height, - this.maxY, - this.maxX, - this.showBottomTitleDates = true, - this.isFullScreeGraph = false, - this.spotColor = AppColors.bgGreenColor, - this.graphColor = AppColors.bgGreenColor, - this.graphShadowColor = AppColors.graphGridColor, - this.graphGridColor = AppColors.graphGridColor, - this.bottomLabelColor = AppColors.textColor, - this.bottomLabelFontWeight = FontWeight.w500, - this.bottomLabelSize, - this.leftLabelInterval, - this.leftLabelReservedSize, - this.makeGraphBasedOnActualValue = false, - required this.bottomLabelFormatter, - this.minX, - this.showGridLines = false, - this.getDrawingHorizontalLine, - this.horizontalInterval, - this.minY, - this.showShadow = false, - this.rangeAnnotations - }); + const CustomGraph( + {super.key, + required this.dataPoints, + required this.leftLabelFormatter, + this.width, + required this.scrollDirection, + required this.height, + this.maxY, + this.maxX, + this.showBottomTitleDates = true, + this.isFullScreeGraph = false, + this.spotColor = AppColors.bgGreenColor, + this.graphColor = AppColors.bgGreenColor, + this.graphShadowColor = AppColors.graphGridColor, + this.graphGridColor = AppColors.graphGridColor, + this.bottomLabelColor = AppColors.textColor, + this.bottomLabelFontWeight = FontWeight.w500, + this.bottomLabelSize, + this.leftLabelInterval, + this.leftLabelReservedSize, + this.bottomLabelReservedSize, + this.makeGraphBasedOnActualValue = false, + required this.bottomLabelFormatter, + this.minX, + this.showGridLines = false, + this.getDrawingHorizontalLine, + this.horizontalInterval, + this.minY, + this.showShadow = false, + this.cutOffY = 0, + this.rangeAnnotations}); @override Widget build(BuildContext context) { return Material( - color: Colors.white, - child: SizedBox( - width: width, - height: height, - child: LineChart( - LineChartData( - minY: minY??0, + color: Colors.white, + child: SizedBox( + width: width, + height: height, + child: LineChart( + LineChartData( + minY: minY ?? 0, maxY: maxY, maxX: maxX, - minX: minX , + minX: minX, lineTouchData: LineTouchData( getTouchLineEnd: (_, __) => 0, getTouchedSpotIndicator: (barData, indicators) { @@ -149,11 +152,8 @@ class CustomGraph extends StatelessWidget { final dataPoint = dataPoints[spot.x.toInt()]; return LineTooltipItem( - '${dataPoint.actualValue} ${dataPoint.unitOfMeasurement??""} - ${dataPoint.displayTime}', - TextStyle( - color: Colors.black, - fontSize: 12.f, - fontWeight: FontWeight.w500), + '${dataPoint.actualValue} ${dataPoint.unitOfMeasurement ?? ""} - ${dataPoint.displayTime}', + TextStyle(color: Colors.black, fontSize: 12.f, fontWeight: FontWeight.w500), ); } return null; // hides the rest @@ -165,7 +165,7 @@ class CustomGraph extends StatelessWidget { leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: leftLabelReservedSize??80, + reservedSize: leftLabelReservedSize ?? 80, interval: leftLabelInterval ?? .1, // Let fl_chart handle it getTitlesWidget: (value, _) { return leftLabelFormatter(value); @@ -176,9 +176,9 @@ class CustomGraph extends StatelessWidget { axisNameSize: 20, sideTitles: SideTitles( showTitles: showBottomTitleDates, - reservedSize: 20, + reservedSize: bottomLabelReservedSize ?? 20, getTitlesWidget: (value, _) { - return bottomLabelFormatter(value, dataPoints, ); + return bottomLabelFormatter(value, dataPoints); }, interval: 1, // ensures 1:1 mapping with spots ), @@ -197,30 +197,29 @@ class CustomGraph extends StatelessWidget { ), lineBarsData: _buildColoredLineSegments(dataPoints), gridData: FlGridData( - show: showGridLines??true, + show: showGridLines ?? true, drawVerticalLine: false, - horizontalInterval:horizontalInterval, + horizontalInterval: horizontalInterval, // checkToShowHorizontalLine: (value) => // value >= 0 && value <= 100, - getDrawingHorizontalLine: getDrawingHorizontalLine??(value) { - return FlLine( - color: graphGridColor, - strokeWidth: 1, - dashArray: [5, 5], - ); - }, + getDrawingHorizontalLine: getDrawingHorizontalLine ?? + (value) { + return FlLine( + color: graphGridColor, + strokeWidth: 1, + dashArray: [5, 5], + ); + }, ), - rangeAnnotations: rangeAnnotations - ), - ), + rangeAnnotations: rangeAnnotations), ), + ), ); } - List _buildColoredLineSegments(List dataPoints) { final List allSpots = dataPoints.asMap().entries.map((entry) { - double value = (makeGraphBasedOnActualValue)?double.tryParse(entry.value.actualValue)??0.0:entry.value.value; + double value = (makeGraphBasedOnActualValue) ? double.tryParse(entry.value.actualValue) ?? 0.0 : entry.value.value; return FlSpot(entry.key.toDouble(), value); }).toList(); @@ -241,6 +240,8 @@ class CustomGraph extends StatelessWidget { ), belowBarData: BarAreaData( show: showShadow, + applyCutOffY: cutOffY != null, + cutOffY: cutOffY ?? 0, gradient: LinearGradient( colors: [ graphShadowColor, @@ -255,4 +256,4 @@ class CustomGraph extends StatelessWidget { return data; } -} \ No newline at end of file +} diff --git a/pubspec.yaml b/pubspec.yaml index 5590c9e..461d3ab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: # firebase_core: ^3.13.1 permission_handler: ^12.0.1 flutter_local_notifications: ^19.4.1 + timezone: ^0.10.0 provider: ^6.1.5+1 get_it: ^8.2.0 just_audio: ^0.10.4