From fd2abbbc7bdccc0d07f28dac4d53015cc8ef98e4 Mon Sep 17 00:00:00 2001 From: faizatflutter Date: Wed, 24 Dec 2025 09:15:18 +0300 Subject: [PATCH] Water Intake Design completed --- assets/images/svg/add_icon_dark.svg | 3 + assets/images/svg/confirm.svg | 2 +- assets/images/svg/cup_add.svg | 5 + assets/images/svg/cup_empty.svg | 4 + assets/images/svg/cup_filled.svg | 5 + assets/images/svg/dumbell_icon.svg | 5 + assets/images/svg/glass_icon.svg | 3 + assets/images/svg/height_icon.svg | 3 + assets/images/svg/minimize_icon.svg | 3 + assets/images/svg/notification_icon_grey.svg | 4 + assets/images/svg/outer_bubbles.svg | 9 + assets/images/svg/profile_icon.svg | 3 + assets/images/svg/water_bottle.svg | 34 ++ assets/images/svg/weight_scale_icon.svg | 3 + lib/core/api/api_client.dart | 135 +++--- lib/core/api_consts.dart | 10 +- lib/core/app_assets.dart | 17 +- lib/core/dependencies.dart | 64 ++- lib/extensions/string_extensions.dart | 98 +++-- .../risk_and_suggestions_response_model.dart | 59 +++ .../symptoms_checker_repo.dart | 229 ++++++++-- .../symptoms_checker_view_model.dart | 350 ++++++++++++++- .../water_monitor/models/water_cup_model.dart | 47 ++ .../water_monitor_view_model.dart | 408 ++++++++++++++++++ lib/main.dart | 6 +- .../appointments/appointment_queue_page.dart | 116 +++-- .../e_referral/new_e_referral.dart | 22 +- .../hmg_services/services_page.dart | 4 +- .../home_health_care/hhc_procedures_page.dart | 112 +++-- .../organ_selector_screen.dart | 17 +- .../symptoms_checker/risk_factors_screen.dart | 274 ++++++------ .../symptoms_checker/suggestions_screen.dart | 237 +++++----- .../symptoms_checker/triage_screen.dart | 14 +- .../water_consumption_screen.dart | 269 ++++++++++++ .../water_monitor_settings_screen.dart | 360 ++++++++++++++++ .../widgets/bottle_shape_clipper.dart | 25 ++ .../widgets/cup_bottomsheet_widgets.dart | 324 ++++++++++++++ .../water_consumption_progress_widget.dart | 86 ++++ .../widgets/water_splash_progress_widget.dart | 351 +++++++++++++++ lib/routes/app_routes.dart | 13 +- lib/theme/colors.dart | 8 + lib/widgets/buttons/custom_button.dart | 2 +- 42 files changed, 3192 insertions(+), 551 deletions(-) create mode 100644 assets/images/svg/add_icon_dark.svg create mode 100644 assets/images/svg/cup_add.svg create mode 100644 assets/images/svg/cup_empty.svg create mode 100644 assets/images/svg/cup_filled.svg create mode 100644 assets/images/svg/dumbell_icon.svg create mode 100644 assets/images/svg/glass_icon.svg create mode 100644 assets/images/svg/height_icon.svg create mode 100644 assets/images/svg/minimize_icon.svg create mode 100644 assets/images/svg/notification_icon_grey.svg create mode 100644 assets/images/svg/outer_bubbles.svg create mode 100644 assets/images/svg/profile_icon.svg create mode 100644 assets/images/svg/water_bottle.svg create mode 100644 assets/images/svg/weight_scale_icon.svg create mode 100644 lib/features/symptoms_checker/models/resp_models/risk_and_suggestions_response_model.dart create mode 100644 lib/features/water_monitor/models/water_cup_model.dart create mode 100644 lib/features/water_monitor/water_monitor_view_model.dart create mode 100644 lib/presentation/water_monitor/water_consumption_screen.dart create mode 100644 lib/presentation/water_monitor/water_monitor_settings_screen.dart create mode 100644 lib/presentation/water_monitor/widgets/bottle_shape_clipper.dart create mode 100644 lib/presentation/water_monitor/widgets/cup_bottomsheet_widgets.dart create mode 100644 lib/presentation/water_monitor/widgets/water_consumption_progress_widget.dart create mode 100644 lib/presentation/water_monitor/widgets/water_splash_progress_widget.dart diff --git a/assets/images/svg/add_icon_dark.svg b/assets/images/svg/add_icon_dark.svg new file mode 100644 index 0000000..399df3c --- /dev/null +++ b/assets/images/svg/add_icon_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/svg/confirm.svg b/assets/images/svg/confirm.svg index 62cfa01..5ed3693 100755 --- a/assets/images/svg/confirm.svg +++ b/assets/images/svg/confirm.svg @@ -1,3 +1,3 @@ - +add_ diff --git a/assets/images/svg/cup_add.svg b/assets/images/svg/cup_add.svg new file mode 100644 index 0000000..ebe186a --- /dev/null +++ b/assets/images/svg/cup_add.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/svg/cup_empty.svg b/assets/images/svg/cup_empty.svg new file mode 100644 index 0000000..fae08fe --- /dev/null +++ b/assets/images/svg/cup_empty.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/svg/cup_filled.svg b/assets/images/svg/cup_filled.svg new file mode 100644 index 0000000..6a085bb --- /dev/null +++ b/assets/images/svg/cup_filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/svg/dumbell_icon.svg b/assets/images/svg/dumbell_icon.svg new file mode 100644 index 0000000..1d6db5f --- /dev/null +++ b/assets/images/svg/dumbell_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/svg/glass_icon.svg b/assets/images/svg/glass_icon.svg new file mode 100644 index 0000000..1df8eec --- /dev/null +++ b/assets/images/svg/glass_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/svg/height_icon.svg b/assets/images/svg/height_icon.svg new file mode 100644 index 0000000..78cefdc --- /dev/null +++ b/assets/images/svg/height_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/svg/minimize_icon.svg b/assets/images/svg/minimize_icon.svg new file mode 100644 index 0000000..b60a041 --- /dev/null +++ b/assets/images/svg/minimize_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/svg/notification_icon_grey.svg b/assets/images/svg/notification_icon_grey.svg new file mode 100644 index 0000000..9e5e8d5 --- /dev/null +++ b/assets/images/svg/notification_icon_grey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/svg/outer_bubbles.svg b/assets/images/svg/outer_bubbles.svg new file mode 100644 index 0000000..cfe860d --- /dev/null +++ b/assets/images/svg/outer_bubbles.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/images/svg/profile_icon.svg b/assets/images/svg/profile_icon.svg new file mode 100644 index 0000000..20dfb2b --- /dev/null +++ b/assets/images/svg/profile_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/svg/water_bottle.svg b/assets/images/svg/water_bottle.svg new file mode 100644 index 0000000..4763d7e --- /dev/null +++ b/assets/images/svg/water_bottle.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/svg/weight_scale_icon.svg b/assets/images/svg/weight_scale_icon.svg new file mode 100644 index 0000000..c3329ff --- /dev/null +++ b/assets/images/svg/weight_scale_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 888f704..f366329 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -19,7 +19,7 @@ abstract class ApiClient { Future post( String endPoint, { - required Map body, + required dynamic body, required Function(dynamic response, int statusCode, {int? messageStatus, String? errorMessage}) onSuccess, required Function(String error, int statusCode, {int? messageStatus, Failure? failureType}) onFailure, bool isAllowAny, @@ -27,6 +27,8 @@ abstract class ApiClient { bool isRCService, bool isPaymentServices, bool bypassConnectionCheck, + Map apiHeaders, + bool isBodyPlainText, }); Future get( @@ -89,7 +91,7 @@ class ApiClientImp implements ApiClient { @override post( String endPoint, { - required Map body, + required dynamic body, required Function(dynamic response, int statusCode, {int? messageStatus, String? errorMessage}) onSuccess, required Function(String error, int statusCode, {int? messageStatus, Failure? failureType}) onFailure, bool isAllowAny = false, @@ -97,6 +99,8 @@ class ApiClientImp implements ApiClient { bool isRCService = false, bool isPaymentServices = false, bool bypassConnectionCheck = true, + Map? apiHeaders, + bool isBodyPlainText = false, }) async { String url; if (isExternal) { @@ -110,80 +114,84 @@ class ApiClientImp implements ApiClient { } // try { var user = _appState.getAuthenticatedUser(); - Map headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}; - if (!isExternal) { - String? token = _appState.appAuthToken; + Map headers = apiHeaders ?? {'Content-Type': 'application/json', 'Accept': 'application/json'}; - if (body.containsKey('SetupID')) { - body['SetupID'] = body.containsKey('SetupID') ? body['SetupID'] ?? body[''] : SETUP_ID; - } else {} + // When isBodyPlainText is true, skip all body manipulation and use body as-is + if (!isBodyPlainText) { + if (!isExternal) { + String? token = _appState.appAuthToken; - if (body.containsKey('isDentalAllowedBackend')) { - body['isDentalAllowedBackend'] = - body.containsKey('isDentalAllowedBackend') ? body['isDentalAllowedBackend'] ?? IS_DENTAL_ALLOWED_BACKEND : IS_DENTAL_ALLOWED_BACKEND; - } + if (body.containsKey('SetupID')) { + body['SetupID'] = body.containsKey('SetupID') ? body['SetupID'] ?? body[''] : SETUP_ID; + } else {} - if (!body.containsKey('IsPublicRequest')) { - // if (!body.containsKey('PatientType')) { - if (user != null && user.patientType != null) { - body['PatientType'] = user.patientType; - } else { - body['PatientType'] = PATIENT_TYPE.toString(); + if (body.containsKey('isDentalAllowedBackend')) { + body['isDentalAllowedBackend'] = + body.containsKey('isDentalAllowedBackend') ? body['isDentalAllowedBackend'] ?? IS_DENTAL_ALLOWED_BACKEND : IS_DENTAL_ALLOWED_BACKEND; } - if (user != null && user.patientType != null) { - body['PatientTypeID'] = user.patientType; - } else { - body['PatientType'] = PATIENT_TYPE_ID.toString(); - } + if (!body.containsKey('IsPublicRequest')) { + // if (!body.containsKey('PatientType')) { + if (user != null && user.patientType != null) { + body['PatientType'] = user.patientType; + } else { + body['PatientType'] = PATIENT_TYPE.toString(); + } + + if (user != null && user.patientType != null) { + body['PatientTypeID'] = user.patientType; + } else { + body['PatientType'] = PATIENT_TYPE_ID.toString(); + } - if (user != null) { - body['TokenID'] = body['TokenID'] ?? token; + if (user != null) { + body['TokenID'] = body['TokenID'] ?? token; - body['PatientID'] = body['PatientID'] ?? user.patientId; + body['PatientID'] = body['PatientID'] ?? user.patientId; - body['PatientOutSA'] = body.containsKey('PatientOutSA') ? body['PatientOutSA'] ?? user.outSa : user.outSa; - body['SessionID'] = body['TokenID'] == null ? ApiConsts.sessionID : getSessionId(body['TokenID'] ?? ""); //getSe + body['PatientOutSA'] = body.containsKey('PatientOutSA') ? body['PatientOutSA'] ?? user.outSa : user.outSa; + body['SessionID'] = body['TokenID'] == null ? ApiConsts.sessionID : getSessionId(body['TokenID'] ?? ""); //getSe + } + // else { + // body['SessionID'] = body['TokenID'] == null ? ApiConsts.sessionID : getSessionId(body['TokenID'] ?? ""); //getSe + // + // } } - // else { - // body['SessionID'] = body['TokenID'] == null ? ApiConsts.sessionID : getSessionId(body['TokenID'] ?? ""); //getSe - // - // } } - } - // request.versionID = VERSION_ID; - // request.channel = CHANNEL; - // request.iPAdress = IP_ADDRESS; - // request.generalid = GENERAL_ID; - // request.languageID = (languageID == 'ar' ? 1 : 2); - // request.patientOutSA = (request.zipCode == '966' || request.zipCode == '+966') ? 0 : 1; - - // body['VersionID'] = ApiConsts.appVersionID.toString(); - if (!isExternal) { - body['VersionID'] = ApiConsts.appVersionID.toString(); - body['Channel'] = ApiConsts.appChannelId.toString(); - body['IPAdress'] = ApiConsts.appIpAddress; - body['generalid'] = ApiConsts.appGeneralId; - - body['LanguageID'] = _appState.getLanguageID().toString(); - body['Latitude'] = _appState.userLat.toString(); - body['Longitude'] = _appState.userLong.toString(); - body['DeviceTypeID'] = _appState.deviceTypeID; - if (_appState.appAuthToken.isNotEmpty) { - body[_appState.isAuthenticated ? 'TokenID' : 'LogInTokenID'] = _appState.appAuthToken; + // request.versionID = VERSION_ID; + // request.channel = CHANNEL; + // request.iPAdress = IP_ADDRESS; + // request.generalid = GENERAL_ID; + // request.languageID = (languageID == 'ar' ? 1 : 2); + // request.patientOutSA = (request.zipCode == '966' || request.zipCode == '+966') ? 0 : 1; + + // body['VersionID'] = ApiConsts.appVersionID.toString(); + if (!isExternal) { + body['VersionID'] = ApiConsts.appVersionID.toString(); + body['Channel'] = ApiConsts.appChannelId.toString(); + body['IPAdress'] = ApiConsts.appIpAddress; + body['generalid'] = ApiConsts.appGeneralId; + + body['LanguageID'] = _appState.getLanguageID().toString(); + body['Latitude'] = _appState.userLat.toString(); + body['Longitude'] = _appState.userLong.toString(); + body['DeviceTypeID'] = _appState.deviceTypeID; + if (_appState.appAuthToken.isNotEmpty) { + body[_appState.isAuthenticated ? 'TokenID' : 'LogInTokenID'] = _appState.appAuthToken; + } + + // body['TokenID'] = "@dm!n"; + // body['PatientID'] = 1018977; + // body['PatientTypeID'] = 1; + // + // body['PatientOutSA'] = 0; + // body['SessionID'] = "45786230487560q"; } - // body['TokenID'] = "@dm!n"; - // body['PatientID'] = 1018977; - // body['PatientTypeID'] = 1; - // - // body['PatientOutSA'] = 0; - // body['SessionID'] = "45786230487560q"; + body.removeWhere((key, value) => value == null); } - body.removeWhere((key, value) => value == null); - final bool networkStatus = await Utils.checkConnection(bypassConnectionCheck: bypassConnectionCheck); if (!networkStatus) { @@ -196,12 +204,13 @@ class ApiClientImp implements ApiClient { return; } - final response = await http.post(Uri.parse(url.trim()), body: json.encode(body), headers: headers); + // Handle body encoding based on isBodyPlainText flag + final dynamic requestBody = isBodyPlainText ? body : json.encode(body); + final response = await http.post(Uri.parse(url.trim()), body: requestBody, headers: headers); final int statusCode = response.statusCode; log("uri: ${Uri.parse(url.trim())}"); log("body: ${json.encode(body)}"); // log("response.body: ${response.body}"); - // log("response.body: ${response.body}"); if (statusCode < 200 || statusCode >= 400) { onFailure('Error While Fetching data', statusCode, failureType: StatusCodeFailure("Error While Fetching data")); logApiEndpointError(endPoint, 'Error While Fetching data', statusCode); diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index 9bab873..a5283c0 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -853,12 +853,11 @@ class ApiConsts { // SYMPTOMS CHECKER static final String getBodySymptomsByName = '$symptomsCheckerApi/GetBodySymptomsByName'; static final String getRiskFactors = '$symptomsCheckerApi/GetRiskFactors'; - static final String getGeneralSuggestion = '$symptomsCheckerApi/GetGeneralSggestion'; + static final String getSuggestions = '$symptomsCheckerApi/GetSuggestion'; static final String diagnosis = '$symptomsCheckerApi/diagnosis'; static final String explain = '$symptomsCheckerApi/explain'; //E-REFERRAL SERVICES - static final getAllRelationshipTypes = "Services/Patients.svc/REST/GetAllRelationshipTypes"; static final sendActivationCodeForEReferral = 'Services/Authentication.svc/REST/SendActivationCodeForEReferral'; static final checkActivationCodeForEReferral = 'Services/Authentication.svc/REST/CheckActivationCodeForEReferral'; @@ -866,6 +865,13 @@ class ApiConsts { static final createEReferral = "Services/Patients.svc/REST/CreateEReferral"; static final getEReferrals = "Services/Patients.svc/REST/GetEReferrals"; + //WATER CONSUMPTION + static String h2oGetUserProgress = "Services/H2ORemainder.svc/REST/H2O_GetUserProgress"; + static String h2oInsertUserActivity = "Services/H2ORemainder.svc/REST/H2O_InsertUserActivity"; + static String h2oGetUserDetail = "Services/H2ORemainder.svc/REST/H2O_GetUserDetails_New"; + static String h2oUpdateUserDetail = "Services/H2ORemainder.svc/REST/H2O_UpdateUserDetails_New"; + static String h2oUndoUserActivity = "Services/H2ORemainder.svc/REST/H2o_UndoUserActivity"; + // ************ static values for Api **************** static final double appVersionID = 50.3; static final int appChannelId = 3; diff --git a/lib/core/app_assets.dart b/lib/core/app_assets.dart index bbe7d75..34224f8 100644 --- a/lib/core/app_assets.dart +++ b/lib/core/app_assets.dart @@ -247,7 +247,22 @@ class AppAssets { static const String refreshIcon = '$svgBasePath/refresh.svg'; static const String homeBorderedIcon = '$svgBasePath/home_bordered.svg'; - // PNGS // + // Water Monitor + static const String waterBottle = '$svgBasePath/water_bottle.svg'; + static const String cupAdd = '$svgBasePath/cup_add.svg'; + static const String cupFilled = '$svgBasePath/cup_filled.svg'; + static const String waterBottleOuterBubbles = '$svgBasePath/outer_bubbles.svg'; + static const String cupEmpty = '$svgBasePath/cup_empty.svg'; + static const String dumbellIcon = '$svgBasePath/dumbell_icon.svg'; + static const String weightScaleIcon = '$svgBasePath/weight_scale_icon.svg'; + static const String heightIcon = '$svgBasePath/height_icon.svg'; + static const String profileIcon = '$svgBasePath/profile_icon.svg'; + static const String notificationIconGrey = '$svgBasePath/notification_icon_grey.svg'; + static const String minimizeIcon = '$svgBasePath/minimize_icon.svg'; + static const String addIconDark = '$svgBasePath/add_icon_dark.svg'; + static const String glassIcon = '$svgBasePath/glass_icon.svg'; + + // PNGS static const String hmgLogo = '$pngBasePath/hmg_logo.png'; static const String liveCareService = '$pngBasePath/livecare_service.png'; diff --git a/lib/core/dependencies.dart b/lib/core/dependencies.dart index 054e1fa..96cbfa9 100644 --- a/lib/core/dependencies.dart +++ b/lib/core/dependencies.dart @@ -45,6 +45,7 @@ import 'package:hmg_patient_app_new/features/symptoms_checker/symptoms_checker_r 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_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_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'; @@ -124,7 +125,10 @@ class AppDependencies { getIt.registerLazySingleton(() => ContactUsRepoImp(loggerService: getIt(), apiClient: getIt())); getIt.registerLazySingleton(() => HmgServicesRepoImp(loggerService: getIt(), apiClient: getIt())); getIt.registerLazySingleton(() => SymptomsCheckerRepoImp(loggerService: getIt(), apiClient: getIt())); - getIt.registerLazySingleton(() => BloodDonationRepoImp(loggerService: getIt(), apiClient: getIt())); + getIt.registerLazySingleton(() => BloodDonationRepoImp( + loggerService: getIt(), + apiClient: getIt(), + )); // ViewModels // Global/shared VMs → LazySingleton @@ -140,10 +144,11 @@ class AppDependencies { getIt.registerLazySingleton(() => InsuranceViewModel(insuranceRepo: getIt(), errorHandlerService: getIt())); - getIt.registerLazySingleton(() => MyAppointmentsViewModel(myAppointmentsRepo: getIt(), errorHandlerService: getIt(), appState: getIt())); + getIt.registerLazySingleton( + () => MyAppointmentsViewModel(myAppointmentsRepo: getIt(), errorHandlerService: getIt(), appState: getIt())); getIt.registerLazySingleton( - () => AppointmentRatingViewModel(myAppointmentsRepo: getIt(), errorHandlerService: getIt(), appState: getIt())); + () => AppointmentRatingViewModel(myAppointmentsRepo: getIt(), errorHandlerService: getIt(), appState: getIt())); getIt.registerLazySingleton( () => PayfortViewModel( @@ -168,7 +173,12 @@ class AppDependencies { getIt.registerLazySingleton( () => BookAppointmentsViewModel( - bookAppointmentsRepo: getIt(), errorHandlerService: getIt(), navigationService: getIt(), myAppointmentsViewModel: getIt(), locationUtils: getIt(), dialogService: getIt()), + bookAppointmentsRepo: getIt(), + errorHandlerService: getIt(), + navigationService: getIt(), + myAppointmentsViewModel: getIt(), + locationUtils: getIt(), + dialogService: getIt()), ); getIt.registerLazySingleton( @@ -182,7 +192,13 @@ class AppDependencies { getIt.registerLazySingleton( () => AuthenticationViewModel( - authenticationRepo: getIt(), cacheService: getIt(), navigationService: getIt(), dialogService: getIt(), appState: getIt(), errorHandlerService: getIt(), localAuthService: getIt()), + authenticationRepo: getIt(), + cacheService: getIt(), + navigationService: getIt(), + dialogService: getIt(), + appState: getIt(), + errorHandlerService: getIt(), + localAuthService: getIt()), ); getIt.registerLazySingleton(() => ProfileSettingsViewModel()); @@ -191,12 +207,21 @@ 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( @@ -213,19 +238,38 @@ class AppDependencies { () => TodoSectionViewModel(todoSectionRepo: getIt(), errorHandlerService: getIt()), ); - getIt.registerLazySingleton(() => SymptomsCheckerViewModel(errorHandlerService: getIt(), symptomsCheckerRepo: getIt())); + getIt.registerLazySingleton( + () => SymptomsCheckerViewModel( + errorHandlerService: getIt(), + symptomsCheckerRepo: getIt(), + appState: getIt(), + ), + ); getIt.registerLazySingleton( - () => HmgServicesViewModel(bookAppointmentsRepo: getIt(), hmgServicesRepo: getIt(), errorHandlerService: getIt(), navigationService: getIt()), + () => HmgServicesViewModel( + bookAppointmentsRepo: getIt(), + hmgServicesRepo: getIt(), + errorHandlerService: getIt(), + navigationService: getIt(), + ), ); getIt.registerLazySingleton( - () => BloodDonationViewModel(bloodDonationRepo: getIt(), errorHandlerService: getIt(), navigationService: getIt(), dialogService: getIt(), appState: getIt()), + () => BloodDonationViewModel( + bloodDonationRepo: getIt(), + errorHandlerService: getIt(), + navigationService: getIt(), + dialogService: getIt(), + appState: getIt(), + ), ); getIt.registerLazySingleton( () => HealthProvider(), ); + getIt.registerLazySingleton(() => WaterMonitorViewModel()); + // Screen-specific VMs → Factory // getIt.registerFactory( // () => BookAppointmentsViewModel( diff --git a/lib/extensions/string_extensions.dart b/lib/extensions/string_extensions.dart index 2039fb8..b4442cb 100644 --- a/lib/extensions/string_extensions.dart +++ b/lib/extensions/string_extensions.dart @@ -41,7 +41,7 @@ extension EmailValidator on String { FontWeight? weight, bool isBold = false, bool isUnderLine = false, - bool isCenter = false, + bool isCenter = false, int? maxlines, FontStyle? fontStyle, TextOverflow? textOverflow, @@ -214,39 +214,38 @@ extension EmailValidator on String { decoration: isUnderLine ? TextDecoration.underline : null), ); - Widget toText16({ - Color? color, - bool isUnderLine = false, - bool isBold = false, - bool isCenter = false, - int? maxlines, - double? height, - TextAlign? textAlign, - FontWeight? weight, - TextOverflow? textOverflow, - double? letterSpacing = -0.4, - Color decorationColor =AppColors.errorColor - }) => + Widget toText16( + {Color? color, + bool isUnderLine = false, + bool isBold = false, + bool isCenter = false, + int? maxlines, + double? height, + TextAlign? textAlign, + FontWeight? weight, + TextOverflow? textOverflow, + double? letterSpacing = -0.4, + Color decorationColor = AppColors.errorColor}) => Text( this, maxLines: maxlines, textAlign: isCenter ? TextAlign.center : null, style: TextStyle( - color: color ?? AppColors.blackColor, - fontSize: 16.f, - letterSpacing: letterSpacing, - height: height, - overflow: textOverflow, - fontWeight: weight ?? (isBold ? FontWeight.bold : FontWeight.normal), - decoration: isUnderLine ? TextDecoration.underline : null, - decorationColor: decorationColor - ), + color: color ?? AppColors.blackColor, + fontSize: 16.f, + letterSpacing: letterSpacing, + height: height, + overflow: textOverflow, + fontWeight: weight ?? (isBold ? FontWeight.bold : FontWeight.normal), + decoration: isUnderLine ? TextDecoration.underline : null, + decorationColor: decorationColor), ); Widget toText17({Color? color, bool isBold = false, bool isCenter = false}) => Text( this, textAlign: isCenter ? TextAlign.center : null, - style: TextStyle(color: color ?? AppColors.blackColor, fontSize: 17.f, letterSpacing: -1, fontWeight: isBold ? FontWeight.bold : FontWeight.normal), + style: TextStyle( + color: color ?? AppColors.blackColor, fontSize: 17.f, letterSpacing: -1, fontWeight: isBold ? FontWeight.bold : FontWeight.normal), ); Widget toText18({Color? color, FontWeight? weight, bool isBold = false, bool isCenter = false, int? maxlines, TextOverflow? textOverflow}) => Text( @@ -255,39 +254,62 @@ extension EmailValidator on String { this, overflow: textOverflow, style: TextStyle( - fontSize: 18.f, fontWeight: weight ?? (isBold ? FontWeight.bold : FontWeight.normal), color: color ?? AppColors.blackColor, letterSpacing: -0.4), + fontSize: 18.f, + fontWeight: weight ?? (isBold ? FontWeight.bold : FontWeight.normal), + color: color ?? AppColors.blackColor, + letterSpacing: -0.4), ); Widget toText19({Color? color, bool isBold = false}) => Text( this, - style: TextStyle(fontSize: 19.f, fontWeight: isBold ? FontWeight.bold : FontWeight.normal, color: color ?? AppColors.blackColor, letterSpacing: -0.4), + style: TextStyle( + fontSize: 19.f, fontWeight: isBold ? FontWeight.bold : FontWeight.normal, color: color ?? AppColors.blackColor, letterSpacing: -0.4), ); - Widget toText20({Color? color, FontWeight? weight, bool isBold = false, }) => Text( + Widget toText20({ + Color? color, + FontWeight? weight, + bool isBold = false, + }) => + Text( this, style: TextStyle( - fontSize: 20.f, fontWeight: weight ?? (isBold ? FontWeight.bold : FontWeight.normal), color: color ?? AppColors.blackColor, letterSpacing: -0.4), + fontSize: 20.f, + fontWeight: weight ?? (isBold ? FontWeight.bold : FontWeight.normal), + color: color ?? AppColors.blackColor, + letterSpacing: -0.4), ); Widget toText21({Color? color, bool isBold = false, FontWeight? weight, int? maxlines}) => Text( this, maxLines: maxlines, style: TextStyle( - color: color ?? AppColors.blackColor, fontSize: 21.f, letterSpacing: -1, fontWeight: weight ?? (isBold ? FontWeight.bold : FontWeight.normal)), + color: color ?? AppColors.blackColor, + fontSize: 21.f, + letterSpacing: -1, + fontWeight: weight ?? (isBold ? FontWeight.bold : FontWeight.normal)), ); Widget toText22({Color? color, bool isBold = false, bool isCenter = false}) => Text( this, textAlign: isCenter ? TextAlign.center : null, style: TextStyle( - height: 1, color: color ?? AppColors.blackColor, fontSize: 22.f, letterSpacing: -1, fontWeight: isBold ? FontWeight.bold : FontWeight.normal), + height: 1, + color: color ?? AppColors.blackColor, + fontSize: 22.f, + letterSpacing: -1, + fontWeight: isBold ? FontWeight.bold : FontWeight.normal), ); Widget toText24({Color? color, bool isBold = false, bool isCenter = false, FontWeight? fontWeight, double? letterSpacing}) => Text( this, textAlign: isCenter ? TextAlign.center : null, style: TextStyle( - height: 23 / 24, color: color ?? AppColors.blackColor, fontSize: 24.f, letterSpacing: letterSpacing??-1, fontWeight: isBold ? FontWeight.bold : fontWeight??FontWeight.normal), + height: 23 / 24, + color: color ?? AppColors.blackColor, + fontSize: 24.f, + letterSpacing: letterSpacing ?? -1, + fontWeight: isBold ? FontWeight.bold : fontWeight ?? FontWeight.normal), ); Widget toText26({Color? color, bool isBold = false, double? height, bool isCenter = false, FontWeight? weight, double? letterSpacing}) => Text( @@ -312,17 +334,25 @@ extension EmailValidator on String { fontWeight: isBold ? FontWeight.bold : FontWeight.normal), ); - Widget toText32({Color? color, bool isBold = false, bool isCenter = false}) => Text( + Widget toText32({FontWeight? weight, Color? color, bool isBold = false, bool isCenter = false}) => Text( this, textAlign: isCenter ? TextAlign.center : null, style: TextStyle( - height: 32 / 32, color: color ?? AppColors.blackColor, fontSize: 32.f, letterSpacing: -1, fontWeight: isBold ? FontWeight.bold : FontWeight.normal), + height: 32 / 32, + color: color ?? AppColors.blackColor, + fontSize: 32.f, + letterSpacing: -1, + fontWeight: isBold ? FontWeight.bold : weight ?? FontWeight.normal), ); Widget toText44({Color? color, bool isBold = false}) => Text( this, style: TextStyle( - height: 32 / 32, color: color ?? AppColors.blackColor, fontSize: 44.f, letterSpacing: -1, fontWeight: isBold ? FontWeight.bold : FontWeight.normal), + height: 32 / 32, + color: color ?? AppColors.blackColor, + fontSize: 44.f, + letterSpacing: -1, + fontWeight: isBold ? FontWeight.bold : FontWeight.normal), ); Widget toSectionHeading({String upperHeading = "", String lowerHeading = ""}) { diff --git a/lib/features/symptoms_checker/models/resp_models/risk_and_suggestions_response_model.dart b/lib/features/symptoms_checker/models/resp_models/risk_and_suggestions_response_model.dart new file mode 100644 index 0000000..b2be4a2 --- /dev/null +++ b/lib/features/symptoms_checker/models/resp_models/risk_and_suggestions_response_model.dart @@ -0,0 +1,59 @@ +class RiskAndSuggestionsResponseModel { + final List? dataDetails; + + RiskAndSuggestionsResponseModel({this.dataDetails}); + + factory RiskAndSuggestionsResponseModel.fromJson(Map json) { + return RiskAndSuggestionsResponseModel( + dataDetails: + json['dataDetails'] != null ? (json['dataDetails'] as List).map((item) => RiskAndSuggestionsItemModel.fromJson(item)).toList() : null, + ); + } + + Map toJson() { + return { + 'dataDetails': dataDetails?.map((item) => item.toJson()).toList(), + }; + } +} + +class RiskAndSuggestionsItemModel { + final String? id; + final String? type; + final String? name; + final String? commonName; + final String? language; + + RiskAndSuggestionsItemModel({ + this.id, + this.type, + this.name, + this.commonName, + this.language, + }); + + factory RiskAndSuggestionsItemModel.fromJson(Map json) { + return RiskAndSuggestionsItemModel( + id: json['id'], + type: json['type'], + name: json['name'], + commonName: json['common_name'], + language: json['language'], + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + 'name': name, + 'common_name': commonName, + 'language': language, + }; + } + + // Helper method to get display name + String getDisplayName() { + return commonName ?? name ?? ''; + } +} diff --git a/lib/features/symptoms_checker/symptoms_checker_repo.dart b/lib/features/symptoms_checker/symptoms_checker_repo.dart index 5379207..954d414 100644 --- a/lib/features/symptoms_checker/symptoms_checker_repo.dart +++ b/lib/features/symptoms_checker/symptoms_checker_repo.dart @@ -7,77 +7,214 @@ import 'package:hmg_patient_app_new/core/api_consts.dart'; import 'package:hmg_patient_app_new/core/common_models/generic_api_model.dart'; import 'package:hmg_patient_app_new/core/exceptions/api_failure.dart'; import 'package:hmg_patient_app_new/features/symptoms_checker/models/resp_models/body_symptom_response_model.dart'; +import 'package:hmg_patient_app_new/features/symptoms_checker/models/resp_models/risk_and_suggestions_response_model.dart'; import 'package:hmg_patient_app_new/services/logger_service.dart'; -import 'package:http/http.dart' as http; abstract class SymptomsCheckerRepo { Future>> getBodySymptomsByName({ required List organNames, }); + + Future>> getRiskFactors({ + required int age, + required String sex, + required List evidenceIds, + required String language, + }); + + Future>> getSuggestions({ + required int age, + required String sex, + required List evidenceIds, + required String language, + }); } class SymptomsCheckerRepoImp implements SymptomsCheckerRepo { final ApiClient apiClient; final LoggerService loggerService; - SymptomsCheckerRepoImp({ - required this.apiClient, - required this.loggerService, - }); + SymptomsCheckerRepoImp({required this.apiClient, required this.loggerService}); @override - Future>> getBodySymptomsByName({ - required List organNames, + Future>> getBodySymptomsByName({required List organNames}) async { + log("GetBodySymptomsByName Request URL: ${ApiConsts.getBodySymptomsByName}"); + log("GetBodySymptomsByName Request Body: ${jsonEncode(organNames)}"); + + Map headers = {'Content-Type': 'application/json', 'Accept': 'text/plain'}; + + try { + GenericApiModel? apiResponse; + Failure? failure; + + await apiClient.post( + ApiConsts.getBodySymptomsByName, + apiHeaders: headers, + body: jsonEncode(organNames), + isExternal: true, + isAllowAny: true, + isBodyPlainText: true, + onFailure: (error, statusCode, {messageStatus, failureType}) { + loggerService.logError("GetBodySymptomsByName API Failed: $error"); + log("GetBodySymptomsByName Failed: $error, Status: $statusCode"); + failure = failureType ?? ServerFailure(error); + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + log("GetBodySymptomsByName Response Status: $statusCode"); + loggerService.logInfo("GetBodySymptomsByName API Success: $response"); + log("GetBodySymptomsByName Response: $response"); + + BodySymptomResponseModel bodySymptomResponse = BodySymptomResponseModel.fromJson(response); + + apiResponse = GenericApiModel( + messageStatus: messageStatus ?? 1, + statusCode: statusCode, + errorMessage: errorMessage, + data: bodySymptomResponse, + ); + } catch (e, stackTrace) { + loggerService.logError("Error parsing GetBodySymptomsByName response: $e"); + loggerService.logError("StackTrace: $stackTrace"); + log("Parse Error: $e"); + failure = DataParsingFailure(e.toString()); + } + }, + ); + + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e, stackTrace) { + loggerService.logError("Exception in getBodySymptomsByName: $e"); + loggerService.logError("StackTrace: $stackTrace"); + log("Exception: $e"); + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future>> getRiskFactors({ + required int age, + required String sex, + required List evidenceIds, + required String language, }) async { + final Map body = { + "age": { + "value": age, + }, + "sex": sex, + "evidence": evidenceIds.map((id) => {"id": id}).toList(), + "language": language, + }; + try { - // API expects a direct JSON array: ["mid_abdomen", "chest"] - // Not an object like: {"organNames": [...]} - // Since ApiClient.post expects Map and encodes it as object, - // we make direct HTTP call here to send array body - - final String requestBody = jsonEncode(organNames); - - loggerService.logInfo("GetBodySymptomsByName Request: $requestBody"); - log("GetBodySymptomsByName Request URL: ${ApiConsts.getBodySymptomsByName}"); - log("GetBodySymptomsByName Request Body: $requestBody"); - - // Make direct HTTP POST request with JSON array body - final response = await http.post( - Uri.parse(ApiConsts.getBodySymptomsByName), - headers: {'Content-Type': 'application/json', 'Accept': 'text/plain'}, - body: requestBody, + GenericApiModel? apiResponse; + Failure? failure; + + await apiClient.post( + ApiConsts.getRiskFactors, + body: body, + isExternal: true, + isAllowAny: true, + onFailure: (error, statusCode, {messageStatus, failureType}) { + log("GetRiskFactors Failed: $error, Status: $statusCode"); + failure = failureType ?? ServerFailure(error); + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + log("GetRiskFactors Response: $response"); + + // Parse response if it's a string + final Map responseData = response is String ? jsonDecode(response) : response; + + RiskAndSuggestionsResponseModel riskFactorsResponse = RiskAndSuggestionsResponseModel.fromJson(responseData); + + apiResponse = GenericApiModel( + messageStatus: messageStatus ?? 1, + statusCode: statusCode, + errorMessage: errorMessage, + data: riskFactorsResponse, + ); + } catch (e, stackTrace) { + loggerService.logError("Error parsing GetRiskFactors response: $e"); + loggerService.logError("StackTrace: $stackTrace"); + log("Parse Error: $e"); + failure = DataParsingFailure(e.toString()); + } + }, ); - final int statusCode = response.statusCode; + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e, stackTrace) { + loggerService.logError("Exception in getRiskFactors: $e"); + loggerService.logError("StackTrace: $stackTrace"); + log("Exception: $e"); + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future>> getSuggestions({ + required int age, + required String sex, + required List evidenceIds, + required String language, + }) async { + final Map body = { + "age": { + "value": age, + }, + "sex": sex, + "evidence": evidenceIds.map((id) => {"id": id}).toList(), + "language": language, + }; - log("GetBodySymptomsByName Response Status: $statusCode"); - loggerService.logInfo("GetBodySymptomsByName Response Status: $statusCode"); + try { + GenericApiModel? apiResponse; + Failure? failure; - try { - // Parse the response - final responseBody = jsonDecode(response.body); + await apiClient.post( + ApiConsts.getSuggestions, + body: body, + isExternal: true, + isAllowAny: true, + onFailure: (error, statusCode, {messageStatus, failureType}) { + log("getSuggestions Failed: $error, Status: $statusCode"); + failure = failureType ?? ServerFailure(error); + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + log("getSuggestions Response: $response"); - loggerService.logInfo("GetBodySymptomsByName API Success: $responseBody"); - log("GetBodySymptomsByName Response: $responseBody"); + // Parse response if it's a string + final Map responseData = response is String ? jsonDecode(response) : response; - BodySymptomResponseModel bodySymptomResponse = BodySymptomResponseModel.fromJson(responseBody); + RiskAndSuggestionsResponseModel riskFactorsResponse = RiskAndSuggestionsResponseModel.fromJson(responseData); - GenericApiModel apiResponse = GenericApiModel( - messageStatus: 1, - statusCode: statusCode, - errorMessage: null, - data: bodySymptomResponse, - ); + apiResponse = GenericApiModel( + messageStatus: messageStatus ?? 1, + statusCode: statusCode, + errorMessage: errorMessage, + data: riskFactorsResponse, + ); + } catch (e, stackTrace) { + loggerService.logError("Error parsing getSuggestions response: $e"); + loggerService.logError("StackTrace: $stackTrace"); + log("Parse Error: $e"); + failure = DataParsingFailure(e.toString()); + } + }, + ); - return Right(apiResponse); - } catch (e, stackTrace) { - loggerService.logError("Error parsing GetBodySymptomsByName response: $e"); - loggerService.logError("StackTrace: $stackTrace"); - log("Parse Error: $e"); - return Left(DataParsingFailure(e.toString())); - } + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); } catch (e, stackTrace) { - loggerService.logError("Exception in getBodySymptomsByName: $e"); + loggerService.logError("Exception in getSuggestions: $e"); loggerService.logError("StackTrace: $stackTrace"); log("Exception: $e"); return Left(UnknownFailure(e.toString())); diff --git a/lib/features/symptoms_checker/symptoms_checker_view_model.dart b/lib/features/symptoms_checker/symptoms_checker_view_model.dart index fe66cf7..da439c6 100644 --- a/lib/features/symptoms_checker/symptoms_checker_view_model.dart +++ b/lib/features/symptoms_checker/symptoms_checker_view_model.dart @@ -1,20 +1,24 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; +import 'package:hmg_patient_app_new/core/app_state.dart'; import 'package:hmg_patient_app_new/core/enums.dart'; import 'package:hmg_patient_app_new/features/symptoms_checker/data/organ_mapping_data.dart'; import 'package:hmg_patient_app_new/features/symptoms_checker/models/organ_model.dart'; import 'package:hmg_patient_app_new/features/symptoms_checker/models/resp_models/body_symptom_response_model.dart'; +import 'package:hmg_patient_app_new/features/symptoms_checker/models/resp_models/risk_and_suggestions_response_model.dart'; import 'package:hmg_patient_app_new/features/symptoms_checker/symptoms_checker_repo.dart'; import 'package:hmg_patient_app_new/services/error_handler_service.dart'; class SymptomsCheckerViewModel extends ChangeNotifier { final SymptomsCheckerRepo symptomsCheckerRepo; final ErrorHandlerService errorHandlerService; + final AppState appState; SymptomsCheckerViewModel({ required this.symptomsCheckerRepo, required this.errorHandlerService, + required this.appState, }); // State variables @@ -29,9 +33,19 @@ class SymptomsCheckerViewModel extends ChangeNotifier { // API loading states bool isBodySymptomsLoading = false; + bool isRiskFactorsLoading = false; + bool isSuggestionsLoading = false; // API data storage - using API models directly BodySymptomResponseModel? bodySymptomResponse; + RiskAndSuggestionsResponseModel? riskFactorsResponse; + RiskAndSuggestionsResponseModel? suggestionsResponse; + + // Selected risk factors tracking + final Set _selectedRiskFactorIds = {}; + + // Selected Suggestions tracking + final Set _selectedSuggestionsIds = {}; // Selected symptoms tracking (organId -> Set of symptom IDs) final Map> _selectedSymptomsByOrgan = {}; @@ -111,6 +125,28 @@ class SymptomsCheckerViewModel extends ChangeNotifier { return _selectedSymptomsByOrgan.values.any((symptomIds) => symptomIds.isNotEmpty); } + /// Get risk factors list + List get riskFactorsList { + return riskFactorsResponse?.dataDetails ?? []; + } + + /// Check if any risk factors are selected + bool get hasSelectedRiskFactors => _selectedRiskFactorIds.isNotEmpty; + + /// Get selected risk factors count + int get selectedRiskFactorsCount => _selectedRiskFactorIds.length; + + /// Check if any risk factors are selected + bool get hasSelectedSuggestions => _selectedSuggestionsIds.isNotEmpty; + + /// Get selected risk factors count + int get selectedSuggestionsCount => _selectedSuggestionsIds.length; + + /// Get risk factors list + List get suggestionsList { + return suggestionsResponse?.dataDetails ?? []; + } + void toggleView() { _currentView = _currentView == BodyView.front ? BodyView.back : BodyView.front; notifyListeners(); @@ -122,6 +158,10 @@ class SymptomsCheckerViewModel extends ChangeNotifier { } void toggleOrganSelection(String organId) { + if (selectedOrganIds.isEmpty && _isBottomSheetExpanded == false) { + toggleBottomSheet(); + } + if (_selectedOrganIds.contains(organId)) { _selectedOrganIds.remove(organId); } else { @@ -131,6 +171,11 @@ class SymptomsCheckerViewModel extends ChangeNotifier { // Show tooltip _showTooltip(organId); + if (_selectedOrganIds.isEmpty) { + _isBottomSheetExpanded = false; + notifyListeners(); + } + notifyListeners(); } @@ -157,6 +202,10 @@ class SymptomsCheckerViewModel extends ChangeNotifier { void removeOrgan(String organId) { _selectedOrganIds.remove(organId); notifyListeners(); + if (_selectedOrganIds.isEmpty) { + _isBottomSheetExpanded = false; + notifyListeners(); + } } void clearAllSelections() { @@ -164,7 +213,12 @@ class SymptomsCheckerViewModel extends ChangeNotifier { notifyListeners(); } - void toggleBottomSheet() { + void toggleBottomSheet({bool? value}) { + if (value != null) { + _isBottomSheetExpanded = value; + notifyListeners(); + return; + } _isBottomSheetExpanded = !_isBottomSheetExpanded; notifyListeners(); } @@ -268,11 +322,305 @@ class SymptomsCheckerViewModel extends ChangeNotifier { notifyListeners(); } + // Risk Factors Methods + + /// Toggle risk factor selection + void toggleRiskFactorSelection(String riskFactorId) { + if (riskFactorId == "not_applicable") { + // "Not applicable" is mutually exclusive: if selected, clear all others + if (_selectedRiskFactorIds.contains(riskFactorId)) { + _selectedRiskFactorIds.remove(riskFactorId); + } else { + _selectedRiskFactorIds + ..clear() + ..add(riskFactorId); + } + } else { + _selectedRiskFactorIds.remove("not_applicable"); + if (!_selectedRiskFactorIds.add(riskFactorId)) { + _selectedRiskFactorIds.remove(riskFactorId); + } + } + + notifyListeners(); + } + + /// Check if risk factor is selected + bool isRiskFactorSelected(String riskFactorId) { + return _selectedRiskFactorIds.contains(riskFactorId); + } + + /// Get all selected risk factors + List getAllSelectedRiskFactors() { + return riskFactorsList.where((factor) => factor.id != null && _selectedRiskFactorIds.contains(factor.id)).toList(); + } + + /// Clear all risk factor selections + void clearAllRiskFactorSelections() { + _selectedRiskFactorIds.clear(); + notifyListeners(); + } + + /// Fetch risk factors based on selected symptoms + Future fetchRiskFactors({ + Function()? onSuccess, + Function(String)? onError, + }) async { + // Get all selected symptoms + final selectedSymptoms = getAllSelectedSymptoms(); + + if (selectedSymptoms.isEmpty) { + if (onError != null) { + onError('No symptoms selected'); + } + return; + } + + // Validate user info + if (_selectedAge == null || _selectedGender == null) { + if (onError != null) { + onError('User information is incomplete'); + } + return; + } + + // Extract symptom IDs + List evidenceIds = selectedSymptoms.where((s) => s.id != null).map((s) => s.id!).toList(); + + await getRiskFactors( + age: _selectedAge!, + sex: _selectedGender!.toLowerCase(), + evidenceIds: evidenceIds, + language: appState.isArabic() ? 'ar' : 'en', + onSuccess: (response) { + if (onSuccess != null) { + onSuccess(); + } + }, + onError: (error) { + if (onError != null) { + onError(error); + } + }, + ); + } + + /// Call Risk Factors API + Future getRiskFactors({ + required int age, + required String sex, + required List evidenceIds, + required String language, + Function(RiskAndSuggestionsResponseModel)? onSuccess, + Function(String)? onError, + }) async { + isRiskFactorsLoading = true; + notifyListeners(); + + final result = await symptomsCheckerRepo.getRiskFactors( + age: age, + sex: sex, + evidenceIds: evidenceIds, + language: language, + ); + + result.fold( + (failure) async { + isRiskFactorsLoading = false; + notifyListeners(); + await errorHandlerService.handleError(failure: failure); + if (onError != null) { + onError(failure.toString()); + } + }, + (apiResponse) { + isRiskFactorsLoading = false; + if (apiResponse.messageStatus == 1 && apiResponse.data != null) { + riskFactorsResponse = apiResponse.data; + + if (riskFactorsResponse != null && riskFactorsResponse!.dataDetails != null) { + RiskAndSuggestionsItemModel riskFactorItem = RiskAndSuggestionsItemModel( + id: "not_applicable", + commonName: "Not Applicable", + name: "Not Applicable", + language: appState.isArabic() ? 'ar' : 'en', + type: null, + ); + riskFactorsResponse!.dataDetails!.add(riskFactorItem); + } + + notifyListeners(); + if (onSuccess != null) { + onSuccess(apiResponse.data!); + } + } else { + notifyListeners(); + if (onError != null) { + onError(apiResponse.errorMessage ?? 'Failed to fetch risk factors'); + } + } + }, + ); + } + + // Suggestions Methods + + /// Toggle suggestions selection + void toggleSuggestionsSelection(String suggestionsId) { + if (suggestionsId == "not_applicable") { + // "Not applicable" is mutually exclusive: if selected, clear all others + if (_selectedSuggestionsIds.contains(suggestionsId)) { + _selectedSuggestionsIds.remove(suggestionsId); + } else { + _selectedSuggestionsIds + ..clear() + ..add(suggestionsId); + } + } else { + _selectedSuggestionsIds.remove("not_applicable"); + if (!_selectedSuggestionsIds.add(suggestionsId)) { + _selectedSuggestionsIds.remove(suggestionsId); + } + } + + notifyListeners(); + } + + /// Check if risk factor is selected + bool isSuggestionsSelected(String riskFactorId) { + return _selectedSuggestionsIds.contains(riskFactorId); + } + + /// Get all selected risk factors + List getAllSelectedSuggestions() { + return suggestionsList.where((factor) => factor.id != null && _selectedSuggestionsIds.contains(factor.id)).toList(); + } + + /// Clear all risk factor selections + void clearAllSuggestionsSelections() { + _selectedSuggestionsIds.clear(); + notifyListeners(); + } + + /// Fetch risk factors based on selected symptoms + Future fetchSuggestions({ + Function()? onSuccess, + Function(String)? onError, + }) async { + // Get all selected symptoms + final selectedSymptoms = getAllSelectedSymptoms(); + + if (selectedSymptoms.isEmpty) { + if (onError != null) { + onError('No symptoms selected'); + } + return; + } + + // Validate user info + if (_selectedAge == null || _selectedGender == null) { + if (onError != null) { + onError('User information is incomplete'); + } + return; + } + + // Extract symptom IDs + List evidenceIds = selectedSymptoms.where((s) => s.id != null).map((s) => s.id!).toList(); + + // Get all selected symptoms + final selectedRisks = getAllSelectedRiskFactors(); + + if (selectedRisks.isNotEmpty) { + List evidenceRisksIds = selectedRisks.where((s) => s.id != null && s.id != "not_applicable").map((s) => s.id!).toList(); + evidenceIds.addAll(evidenceRisksIds); + } + + await getSuggestions( + age: _selectedAge!, + sex: _selectedGender!.toLowerCase(), + evidenceIds: evidenceIds, + language: appState.isArabic() ? 'ar' : 'en', + onSuccess: (response) { + if (onSuccess != null) { + onSuccess(); + } + }, + onError: (error) { + if (onError != null) { + onError(error); + } + }, + ); + } + + /// Call Suggestions API + Future getSuggestions({ + required int age, + required String sex, + required List evidenceIds, + required String language, + Function(RiskAndSuggestionsResponseModel)? onSuccess, + Function(String)? onError, + }) async { + isSuggestionsLoading = true; + notifyListeners(); + + final result = await symptomsCheckerRepo.getSuggestions( + age: age, + sex: sex, + evidenceIds: evidenceIds, + language: language, + ); + + result.fold( + (failure) async { + isSuggestionsLoading = false; + notifyListeners(); + await errorHandlerService.handleError(failure: failure); + if (onError != null) { + onError(failure.toString()); + } + }, + (apiResponse) { + isSuggestionsLoading = false; + if (apiResponse.messageStatus == 1 && apiResponse.data != null) { + suggestionsResponse = apiResponse.data; + + if (suggestionsResponse != null && suggestionsResponse!.dataDetails != null) { + RiskAndSuggestionsItemModel riskFactorItem = RiskAndSuggestionsItemModel( + id: "not_applicable", + commonName: "Not Applicable", + name: "Not Applicable", + language: appState.isArabic() ? 'ar' : 'en', + type: null, + ); + suggestionsResponse!.dataDetails!.add(riskFactorItem); + } + + notifyListeners(); + if (onSuccess != null) { + onSuccess(apiResponse.data!); + } + } else { + notifyListeners(); + if (onError != null) { + onError(apiResponse.errorMessage ?? 'Failed to fetch risk factors'); + } + } + }, + ); + } + void reset() { _currentView = BodyView.front; _selectedOrganIds.clear(); _selectedSymptomsByOrgan.clear(); + _selectedRiskFactorIds.clear(); + _selectedSuggestionsIds.clear(); bodySymptomResponse = null; + riskFactorsResponse = null; + suggestionsResponse = null; _isBottomSheetExpanded = false; _tooltipTimer?.cancel(); _tooltipOrganId = null; diff --git a/lib/features/water_monitor/models/water_cup_model.dart b/lib/features/water_monitor/models/water_cup_model.dart new file mode 100644 index 0000000..b86fae4 --- /dev/null +++ b/lib/features/water_monitor/models/water_cup_model.dart @@ -0,0 +1,47 @@ +class WaterCupModel { + final String id; + final String name; + final int capacityMl; + final String iconPath; // or use IconData if you prefer + final bool isDefault; + + WaterCupModel({ + required this.id, + required this.name, + required this.capacityMl, + required this.iconPath, + this.isDefault = false, + }); + + WaterCupModel copyWith({ + String? id, + String? name, + int? capacityMl, + String? iconPath, + bool? isDefault, + }) { + return WaterCupModel( + id: id ?? this.id, + name: name ?? this.name, + capacityMl: capacityMl ?? this.capacityMl, + iconPath: iconPath ?? this.iconPath, + isDefault: isDefault ?? this.isDefault, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'capacityMl': capacityMl, + 'iconPath': iconPath, + 'isDefault': isDefault, + }; + + factory WaterCupModel.fromJson(Map json) => WaterCupModel( + id: json['id'], + name: json['name'], + capacityMl: json['capacityMl'], + iconPath: json['iconPath'], + isDefault: json['isDefault'] ?? false, + ); +} diff --git a/lib/features/water_monitor/water_monitor_view_model.dart b/lib/features/water_monitor/water_monitor_view_model.dart new file mode 100644 index 0000000..4e8d5f5 --- /dev/null +++ b/lib/features/water_monitor/water_monitor_view_model.dart @@ -0,0 +1,408 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/app_assets.dart'; +import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; +import 'package:hmg_patient_app_new/features/water_monitor/models/water_cup_model.dart'; + +class WaterMonitorViewModel extends ChangeNotifier { + // Controllers + final TextEditingController nameController = TextEditingController(); + final TextEditingController heightController = TextEditingController(); + final TextEditingController weightController = TextEditingController(); + final TextEditingController ageController = TextEditingController(); + + // Units + final List heightUnits = ['cm', 'm', 'ft', 'in']; + final List weightUnits = ['kg', 'lb']; + + // Selected values + String _selectedHeightUnit = 'cm'; + String _selectedWeightUnit = 'kg'; + String _selectedActivityLevel = 'Lightly active'; + String _selectedNumberOfReminders = '3 Time'; + String _selectedGender = "Male"; + + // Validation error message + String? _validationError; + + // Getters + String? get validationError => _validationError; + + String get selectedHeightUnit => _selectedHeightUnit; + + String get selectedWeightUnit => _selectedWeightUnit; + + String get selectedActivityLevel => _selectedActivityLevel; + + String get selectedNumberOfReminders => _selectedNumberOfReminders; + + String get selectedGender => _selectedGender; + + // Activity level options + List get activityLevels => + ["Almost Inactive (no exercise)", "Lightly active", "Lightly active (1-3) days per week", "Super active (very hard exercise)"]; + + // Reminder options + List get reminderOptions => ["1 Time", "2 Time", "3 Time", "4 Time", "5 Time", "6 Time"]; + + // Gender options + List get genderOptions => ["Male", "Female"]; + + // Initialize method to be called when needed + void initialize() { + _loadSettings(); + _initializeDefaultCups(); + } + + // Reset all fields to default + void resetFields() { + nameController.clear(); + heightController.clear(); + weightController.clear(); + ageController.clear(); + _selectedHeightUnit = 'cm'; + _selectedWeightUnit = 'kg'; + _selectedActivityLevel = 'Lightly active'; + _selectedNumberOfReminders = '3 Time'; + _selectedGender = "Male"; + notifyListeners(); + } + + // Setters with notification + void setGender(String gender) { + _selectedGender = gender; + notifyListeners(); + } + + void setHeightUnit(String unit) { + _selectedHeightUnit = unit; + notifyListeners(); + } + + void setWeightUnit(String unit) { + _selectedWeightUnit = unit; + notifyListeners(); + } + + void setActivityLevel(String level) { + _selectedActivityLevel = level; + notifyListeners(); + } + + void setNumberOfReminders(String number) { + _selectedNumberOfReminders = number; + notifyListeners(); + } + + // Validation methods + bool get isValid { + _validationError = null; // Clear previous error + + if (nameController.text.trim().isEmpty) { + _validationError = 'Name is required'; + return false; + } + + if (ageController.text.trim().isEmpty) { + _validationError = 'Age is required'; + return false; + } + + if (!_isAgeValid()) { + _validationError = validateAge(); + return false; + } + + if (heightController.text.trim().isEmpty) { + _validationError = 'Height is required'; + return false; + } + + if (!_isHeightValid()) { + _validationError = validateHeight(); + return false; + } + + if (weightController.text.trim().isEmpty) { + _validationError = 'Weight is required'; + return false; + } + + if (!_isWeightValid()) { + _validationError = validateWeight(); + return false; + } + + return true; + } + + bool _isAgeValid() { + final age = int.tryParse(ageController.text.trim()); + return age != null && age >= 11 && age <= 120; + } + + bool _isHeightValid() { + final height = double.tryParse(heightController.text.trim()); + return height != null && height > 0; + } + + bool _isWeightValid() { + final weight = double.tryParse(weightController.text.trim()); + return weight != null && weight > 0; + } + + String? validateAge() { + if (ageController.text.trim().isEmpty) { + return 'Age is required'.needTranslation; + } + final age = int.tryParse(ageController.text.trim()); + if (age == null) { + return 'Invalid age'.needTranslation; + } + if (age < 11 || age > 120) { + return 'Age must be between 11 and 120'.needTranslation; + } + return null; + } + + String? validateHeight() { + if (heightController.text.trim().isEmpty) { + return 'Height is required'.needTranslation; + } + final height = double.tryParse(heightController.text.trim()); + if (height == null || height <= 0) { + return 'Invalid height'.needTranslation; + } + return null; + } + + String? validateWeight() { + if (weightController.text.trim().isEmpty) { + return 'Weight is required'.needTranslation; + } + final weight = double.tryParse(weightController.text.trim()); + if (weight == null || weight <= 0) { + return 'Invalid weight'.needTranslation; + } + 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; + } + } + + // Save settings + Future saveSettings() async { + if (!isValid) { + notifyListeners(); // Notify so error can be read + return false; + } + _validationError = null; // Clear error on success + notifyListeners(); + return true; + } + + // Load settings + Future _loadSettings() async { + notifyListeners(); + } + + @override + void dispose() { + nameController.dispose(); + heightController.dispose(); + weightController.dispose(); + ageController.dispose(); + super.dispose(); + } + + List _cups = []; + String? _selectedCupId; + int _totalConsumedMl = 1000; + int _dailyGoalMl = 2000; + // Calibration: portion of the bottle SVG height that is fillable (0.0 - 1.0) + double _fillableHeightPercent = 0.823; + + List get cups => _cups; + + WaterCupModel? get selectedCup { + if (_cups.isEmpty) return null; + return _cups.firstWhere((c) => c.id == _selectedCupId, orElse: () => _cups.first); + } + + int get totalConsumedMl => _totalConsumedMl; + + int get dailyGoalMl => _dailyGoalMl; + + // Portion of bottle drawable height that is fillable. + double get fillableHeightPercent => _fillableHeightPercent; + + void setFillableHeightPercent(double v) { + _fillableHeightPercent = v.clamp(0.0, 1.0); + notifyListeners(); + } + + // Normalized progress in 0.0 - 1.0 (ensure double) + double get progress { + if (_dailyGoalMl == 0) return 0.0; + final p = _totalConsumedMl / _dailyGoalMl; + return p.clamp(0.0, 1.0).toDouble(); + } + + // Convenience percent (0 - 100) + double get progressPercent => (progress * 100.0).clamp(0.0, 100.0).toDouble(); + + // Allow updating consumed and goal through the vm so UI doesn't manipulate internal fields directly + void setTotalConsumedMl(int ml) { + _totalConsumedMl = ml; + notifyListeners(); + } + + void addConsumedMl(int ml) { + _totalConsumedMl = (_totalConsumedMl + ml).clamp(0, 1000000); + notifyListeners(); + } + + void subtractConsumedMl(int ml) { + _totalConsumedMl = (_totalConsumedMl - ml).clamp(0, 1000000); + notifyListeners(); + } + + void setDailyGoal(int ml) { + _dailyGoalMl = ml; + notifyListeners(); + } + + void _initializeDefaultCups() { + _cups = [ + WaterCupModel( + id: 'default_125', + name: '125ml', + capacityMl: 125, + iconPath: AppAssets.cupEmpty, + isDefault: true, + ), + WaterCupModel( + id: 'default_150', + name: '150ml', + capacityMl: 150, + iconPath: AppAssets.cupEmpty, + isDefault: true, + ), + WaterCupModel( + id: 'default_175', + name: '175ml', + capacityMl: 175, + iconPath: AppAssets.cupEmpty, + isDefault: true, + ), + WaterCupModel( + id: 'default_200', + name: '200ml', + capacityMl: 200, + iconPath: AppAssets.cupEmpty, + isDefault: true, + ), + WaterCupModel( + id: 'default_250', + name: '250ml', + capacityMl: 250, + iconPath: AppAssets.cupEmpty, + isDefault: true, + ), + WaterCupModel( + id: 'default_300', + name: '300ml', + capacityMl: 300, + iconPath: AppAssets.cupEmpty, + isDefault: true, + ), + ]; + _selectedCupId = _cups.first.id; + notifyListeners(); + } + + void selectCup(String cupId) { + _selectedCupId = cupId; + notifyListeners(); + } + + void addCup(WaterCupModel cup) { + _cups.add(cup); + notifyListeners(); + } + + void updateCup(WaterCupModel updatedCup) { + final index = _cups.indexWhere((c) => c.id == updatedCup.id); + if (index != -1) { + _cups[index] = updatedCup; + notifyListeners(); + } + } + + // Public alias for deleting/removing a cup. Keeps API intention clear in UI code. + void removeCup(String cupId) { + deleteCup(cupId); + } + + void deleteCup(String cupId) { + final cup = _cups.firstWhere((c) => c.id == cupId); + if (cup.isDefault) return; // can't delete default cups + + _cups.removeWhere((c) => c.id == cupId); + if (_selectedCupId == cupId) { + _selectedCupId = _cups.first.id; + } + notifyListeners(); + } + + void addWaterIntake() { + if (selectedCup != null) { + _totalConsumedMl += selectedCup!.capacityMl; + notifyListeners(); + } + } + + void resetDaily() { + _totalConsumedMl = 0; + notifyListeners(); + } +} diff --git a/lib/main.dart b/lib/main.dart index ed69150..e974d6c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,8 +30,9 @@ import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_view_mo import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart'; import 'package:hmg_patient_app_new/features/radiology/radiology_view_model.dart'; import 'package:hmg_patient_app_new/features/smartwatch_health_data/health_provider.dart'; -import 'package:hmg_patient_app_new/features/todo_section/todo_section_view_model.dart'; 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/routes/app_routes.dart'; import 'package:hmg_patient_app_new/services/logger_service.dart'; @@ -165,6 +166,9 @@ void main() async { ), ChangeNotifierProvider( create: (_) => getIt.get(), + ), + ChangeNotifierProvider( + create: (_) => getIt.get(), ) ], child: MyApp()), ), diff --git a/lib/presentation/appointments/appointment_queue_page.dart b/lib/presentation/appointments/appointment_queue_page.dart index 4554873..124bf25 100644 --- a/lib/presentation/appointments/appointment_queue_page.dart +++ b/lib/presentation/appointments/appointment_queue_page.dart @@ -42,8 +42,11 @@ class AppointmentQueuePage extends StatelessWidget { color: AppColors.whiteColor, borderRadius: 20.h, hasShadow: false, - side: - BorderSide(color: myAppointmentsVM.isAppointmentQueueDetailsLoading ? AppColors.whiteColor : Utils.getCardBorderColor(myAppointmentsVM.currentQueueStatus), width: 2.w), + side: BorderSide( + color: myAppointmentsVM.isAppointmentQueueDetailsLoading + ? AppColors.whiteColor + : Utils.getCardBorderColor(myAppointmentsVM.currentQueueStatus), + width: 2.w), ), child: Padding( padding: EdgeInsets.all(16.h), @@ -62,17 +65,23 @@ class AppointmentQueuePage extends StatelessWidget { ], ).toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), SizedBox(height: 10.h), - "Hala ${appState!.getAuthenticatedUser()!.firstName}!!!".needTranslation.toText16(isBold: true).toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), + "Hala ${appState!.getAuthenticatedUser()!.firstName}!!!" + .needTranslation + .toText16(isBold: true) + .toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), SizedBox(height: 8.h), "Thank you for your patience, here is your queue number." .needTranslation .toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight) .toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), SizedBox(height: 8.h), - myAppointmentsVM.currentPatientQueueDetails.queueNo!.toText32(isBold: true).toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), + myAppointmentsVM.currentPatientQueueDetails.queueNo! + .toText32(isBold: true) + .toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), SizedBox(height: 8.h), CustomButton( - text: Utils.getCardButtonText(myAppointmentsVM.currentQueueStatus, myAppointmentsVM.currentPatientQueueDetails.roomNo ?? ""), + text: Utils.getCardButtonText( + myAppointmentsVM.currentQueueStatus, myAppointmentsVM.currentPatientQueueDetails.roomNo ?? ""), onPressed: () {}, backgroundColor: Utils.getCardButtonColor(myAppointmentsVM.currentQueueStatus), borderColor: Utils.getCardButtonColor(myAppointmentsVM.currentQueueStatus).withValues(alpha: 0.01), @@ -94,61 +103,72 @@ class AppointmentQueuePage extends StatelessWidget { ? Container( decoration: RoundedRectangleBorder().toSmoothCornerDecoration( color: AppColors.whiteColor, - borderRadius: 20.h, - hasShadow: true, - ), - child: Padding( - padding: EdgeInsets.all(16.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - "Serving Now".needTranslation.toText16(isBold: true).toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), + borderRadius: 20.h, + hasShadow: true, + ), + child: Padding( + padding: EdgeInsets.all(16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + "Serving Now" + .needTranslation + .toText16(isBold: true) + .toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), SizedBox(height: 18.h), ListView.separated( - padding: EdgeInsets.zero, - shrinkWrap: true, + padding: EdgeInsets.zero, + shrinkWrap: true, itemCount: myAppointmentsVM.patientQueueDetailsList.length, physics: NeverScrollableScrollPhysics(), itemBuilder: (BuildContext context, int index) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - myAppointmentsVM.patientQueueDetailsList[index].queueNo!.toText17(isBold: true), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - "Room: ${myAppointmentsVM.patientQueueDetailsList[index].roomNo}".toText12(fontWeight: FontWeight.w500), + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + myAppointmentsVM.patientQueueDetailsList[index].queueNo!.toText17(isBold: true), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + "Room: ${myAppointmentsVM.patientQueueDetailsList[index].roomNo}" + .toText12(fontWeight: FontWeight.w500), SizedBox(width: 8.w), AppCustomChipWidget( - deleteIcon: myAppointmentsVM.patientQueueDetailsList[index].callType == 1 ? AppAssets.call_for_vitals : AppAssets.call_for_doctor, - labelText: - myAppointmentsVM.patientQueueDetailsList[index].callType == 1 ? "Call for vital signs".needTranslation : "Call for Doctor".needTranslation, - iconColor: myAppointmentsVM.patientQueueDetailsList[index].callType == 1 ? AppColors.primaryRedColor : AppColors.successColor, - textColor: myAppointmentsVM.patientQueueDetailsList[index].callType == 1 ? AppColors.primaryRedColor : AppColors.successColor, + deleteIcon: myAppointmentsVM.patientQueueDetailsList[index].callType == 1 + ? AppAssets.call_for_vitals + : AppAssets.call_for_doctor, + labelText: myAppointmentsVM.patientQueueDetailsList[index].callType == 1 + ? "Call for vital signs".needTranslation + : "Call for Doctor".needTranslation, + iconColor: myAppointmentsVM.patientQueueDetailsList[index].callType == 1 + ? AppColors.primaryRedColor + : AppColors.successColor, + textColor: myAppointmentsVM.patientQueueDetailsList[index].callType == 1 + ? AppColors.primaryRedColor + : AppColors.successColor, iconSize: 14.w, backgroundColor: myAppointmentsVM.patientQueueDetailsList[index].callType == 1 ? AppColors.primaryRedColor.withValues(alpha: 0.1) : AppColors.successColor.withValues(alpha: 0.1), labelPadding: EdgeInsetsDirectional.only(start: 8.h, end: -2.h), ), - ], - ), - ], - ); - }, - separatorBuilder: (BuildContext cxt, int index) => SizedBox(height: 8.h), + ], + ), + ], + ); + }, + separatorBuilder: (BuildContext cxt, int index) => SizedBox(height: 8.h), ).toShimmer2(isShow: myAppointmentsVM.isAppointmentQueueDetailsLoading), ], ), - ), + ), ) : SizedBox.shrink(), SizedBox(height: 16.h), Container( decoration: RoundedRectangleBorder().toSmoothCornerDecoration( color: AppColors.whiteColor, - borderRadius: 20.h, + borderRadius: 24.4, hasShadow: true, ), child: Padding( @@ -171,15 +191,25 @@ class AppointmentQueuePage extends StatelessWidget { // Are there any side effects I should know about? // When should I come back for a follow-up? - "• ${"What can I do to improve my overall health?"}".needTranslation.toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), + "• ${"What can I do to improve my overall health?"}" + .needTranslation + .toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), SizedBox(height: 4.h), - "• ${"Are there any routine screenings I should get?"}".needTranslation.toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), + "• ${"Are there any routine screenings I should get?"}" + .needTranslation + .toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), SizedBox(height: 4.h), - "• ${"What is this medication for?"}".needTranslation.toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), + "• ${"What is this medication for?"}" + .needTranslation + .toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), SizedBox(height: 4.h), - "• ${"Are there any side effects I should know about?"}".needTranslation.toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), + "• ${"Are there any side effects I should know about?"}" + .needTranslation + .toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), SizedBox(height: 4.h), - "• ${"When should I come back for a follow-up?"}".needTranslation.toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), + "• ${"When should I come back for a follow-up?"}" + .needTranslation + .toText12(fontWeight: FontWeight.w500, color: AppColors.textColorLight), SizedBox(height: 16.h), ], diff --git a/lib/presentation/e_referral/new_e_referral.dart b/lib/presentation/e_referral/new_e_referral.dart index b28b8df..3083de1 100644 --- a/lib/presentation/e_referral/new_e_referral.dart +++ b/lib/presentation/e_referral/new_e_referral.dart @@ -24,6 +24,7 @@ import 'package:hmg_patient_app_new/widgets/loader/bottomsheet_loader.dart'; import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart'; import 'package:hmg_patient_app_new/widgets/stepper/stepper_widget.dart'; import 'package:provider/provider.dart'; + import 'e-referral_validator.dart'; import 'e_referral_form_manager.dart'; @@ -43,7 +44,6 @@ class _NewReferralPageState extends State { double widthOfOneState = ((ResponsiveExtension.screenWidth) / 3) - (20.h); - @override void initState() { super.initState(); @@ -116,7 +116,7 @@ class _NewReferralPageState extends State { cityCode: _formManager.formData.patientCity!.iD!.toString(), cityName: _formManager.formData.patientCity!.description, requesterName: _formManager.formData.requesterName, - requesterContactNo: _formManager.formData.countryEnum.countryCode + _formManager.formData.requesterPhone, + requesterContactNo: _formManager.formData.countryEnum.countryCode + _formManager.formData.requesterPhone, requesterRelationship: _formManager.formData.relationship?.iD, otherRelationship: _formManager.formData.relationship!.iD.toString(), fullName: _formManager.formData.patientName, @@ -133,10 +133,8 @@ class _NewReferralPageState extends State { hmgServicesVM.createEReferral( requestModel: createReferralRequestModel, onSuccess: (GenericApiModel response) { - showSuccessBottomSheet(int.parse(response.data), hmgServicesVM); LoaderBottomSheet.hideLoader(); - }, onError: (errorMessage) { // Handle error (e.g., show error message) @@ -146,9 +144,6 @@ class _NewReferralPageState extends State { } void _loadData() { - - - final authVM = context.read(); final habibWalletVM = context.read(); final hmgServicesVM = context.read(); @@ -179,7 +174,7 @@ class _NewReferralPageState extends State { color: Colors.white, padding: EdgeInsets.all(ResponsiveExtension(20).h), child: CustomButton( - text: _currentStep <=1 ? LocaleKeys.next.tr() : LocaleKeys.submit.tr(), + text: _currentStep <= 1 ? LocaleKeys.next.tr() : LocaleKeys.submit.tr(), // icon: AppAssets.search_icon, iconColor: Colors.white, onPressed: () => {_handleNextStep()}, @@ -188,7 +183,7 @@ class _NewReferralPageState extends State { child: ChangeNotifierProvider.value( value: _formManager, child: SizedBox( - height: ResponsiveExtension.screenHeight * 0.65, + height: ResponsiveExtension.screenHeight * 0.65, child: Column( children: [ const SizedBox(height: 8), @@ -220,7 +215,6 @@ class _NewReferralPageState extends State { // ); } - showSuccessBottomSheet(int requestId, HmgServicesViewModel hmgServicesViewModel) { return showCommonBottomSheetWithoutHeight( context, @@ -232,9 +226,9 @@ class _NewReferralPageState extends State { Row( children: [ "Here is your Referral #: ".needTranslation.toText14( - color: AppColors.textColorLight, - weight: FontWeight.w500, - ), + color: AppColors.textColorLight, + weight: FontWeight.w500, + ), SizedBox(width: 4.w), ("$requestId").toText16(isBold: true), ], @@ -249,7 +243,7 @@ class _NewReferralPageState extends State { onPressed: () { context.pop(); context.pop(); - _currentStep =0; + _currentStep = 0; }, textColor: AppColors.whiteColor, ), diff --git a/lib/presentation/hmg_services/services_page.dart b/lib/presentation/hmg_services/services_page.dart index b321ff3..5b7113b 100644 --- a/lib/presentation/hmg_services/services_page.dart +++ b/lib/presentation/hmg_services/services_page.dart @@ -131,7 +131,7 @@ class ServicesPage extends StatelessWidget { AppAssets.daily_water_monitor_icon, bgColor: AppColors.whiteColor, true, - route: AppRoutes.eReferralPage, + route: AppRoutes.waterConsumptionScreen, ), HmgServicesComponentModel( 11, @@ -374,7 +374,7 @@ class ServicesPage extends StatelessWidget { SizedBox(height: 16.h), GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount:(isFoldable || isTablet) ? 6 : 4, // 4 icons per row + crossAxisCount: (isFoldable || isTablet) ? 6 : 4, // 4 icons per row crossAxisSpacing: 12.w, mainAxisSpacing: 18.h, childAspectRatio: 0.8, diff --git a/lib/presentation/home_health_care/hhc_procedures_page.dart b/lib/presentation/home_health_care/hhc_procedures_page.dart index 41d8a2f..be97a88 100644 --- a/lib/presentation/home_health_care/hhc_procedures_page.dart +++ b/lib/presentation/home_health_care/hhc_procedures_page.dart @@ -446,68 +446,62 @@ class _HhcProceduresPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.bgScaffoldColor, - body: Column( - children: [ - Expanded( - child: CollapsingListView( - title: "Home Health Care".needTranslation, - history: () => Navigator.of(context).push(CustomPageRoute(page: HhcOrderDetailPage(), direction: AxisDirection.up)), - child: Consumer( - builder: (context, hmgServicesViewModel, child) { - if (hmgServicesViewModel.isHhcOrdersLoading || hmgServicesViewModel.isHhcServicesLoading) { - return _buildLoadingShimmer(); - } - final pendingOrder = _getPendingOrder(hmgServicesViewModel.hhcOrdersList); - if (pendingOrder != null) { - return _buildPendingOrderCard(pendingOrder); - } else { - return Column( - children: [ - Center( - child: Utils.getNoDataWidget( - context, - noDataText: "You have no pending requests.".needTranslation, - ), - ), - ], - ); - } - }, - ), - ), - ), - Consumer( - builder: (BuildContext context, HmgServicesViewModel hmgServicesViewModel, Widget? child) { - if (hmgServicesViewModel.isHhcOrdersLoading || hmgServicesViewModel.isHhcServicesLoading) { - return SizedBox.shrink(); - } - final pendingOrder = _getPendingOrder(hmgServicesViewModel.hhcOrdersList); - if (pendingOrder == null) { - return Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 24.r, - hasShadow: true, + body: CollapsingListView( + title: "Home Health Care".needTranslation, + history: () => Navigator.of(context).push(CustomPageRoute(page: HhcOrderDetailPage(), direction: AxisDirection.up)), + bottomChild: Consumer( + builder: (BuildContext context, HmgServicesViewModel hmgServicesViewModel, Widget? child) { + if (hmgServicesViewModel.isHhcOrdersLoading || hmgServicesViewModel.isHhcServicesLoading) { + return SizedBox.shrink(); + } + final pendingOrder = _getPendingOrder(hmgServicesViewModel.hhcOrdersList); + if (pendingOrder == null) { + return Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.r, + hasShadow: true, + ), + child: Padding( + padding: EdgeInsets.all(24.w), + child: CustomButton( + borderWidth: 0, + text: "Create new request".needTranslation, + onPressed: () => _buildServicesListBottomsSheet(hmgServicesViewModel.hhcServicesList), + textColor: AppColors.whiteColor, + borderRadius: 12.r, + borderColor: Colors.transparent, + padding: EdgeInsets.symmetric(vertical: 14.h), ), - child: Padding( - padding: EdgeInsets.only(left: 16.w, right: 16.w, bottom: 24.h, top: 24.h), - child: CustomButton( - borderWidth: 0, - text: "Create new request".needTranslation, - onPressed: () => _buildServicesListBottomsSheet(hmgServicesViewModel.hhcServicesList), - textColor: AppColors.whiteColor, - borderRadius: 12.r, - borderColor: Colors.transparent, - padding: EdgeInsets.symmetric(vertical: 14.h), + ), + ); + } + + return SizedBox.shrink(); + }, + ), + child: Consumer( + builder: (context, hmgServicesViewModel, child) { + if (hmgServicesViewModel.isHhcOrdersLoading || hmgServicesViewModel.isHhcServicesLoading) { + return _buildLoadingShimmer(); + } + final pendingOrder = _getPendingOrder(hmgServicesViewModel.hhcOrdersList); + if (pendingOrder != null) { + return _buildPendingOrderCard(pendingOrder); + } else { + return Column( + children: [ + Center( + child: Utils.getNoDataWidget( + context, + noDataText: "You have no pending requests.".needTranslation, ), ), - ); - } - - return SizedBox.shrink(); - }, - ), - ], + ], + ); + } + }, + ), ), ); } diff --git a/lib/presentation/symptoms_checker/organ_selector_screen.dart b/lib/presentation/symptoms_checker/organ_selector_screen.dart index d5dc32c..c23b956 100644 --- a/lib/presentation/symptoms_checker/organ_selector_screen.dart +++ b/lib/presentation/symptoms_checker/organ_selector_screen.dart @@ -267,14 +267,15 @@ class _OrganSelectorPageState extends State { runSpacing: 8.h, children: viewModel.selectedOrgans.map((organ) { return AppCustomChipWidget( - labelText: organ.description, - backgroundColor: AppColors.secondaryLightRedColor, - textColor: AppColors.primaryRedColor, - deleteIcon: AppAssets.cancel, - deleteIconColor: AppColors.primaryRedColor, - deleteIconHasColor: false, - onDeleteTap: () => viewModel.removeOrgan(organ.id), - ); + labelText: organ.description, + backgroundColor: AppColors.secondaryLightRedColor, + textColor: AppColors.primaryRedColor, + deleteIcon: AppAssets.cancel, + deleteIconColor: AppColors.primaryRedColor, + deleteIconHasColor: false, + onDeleteTap: () { + viewModel.removeOrgan(organ.id); + }); }).toList(), ), ), diff --git a/lib/presentation/symptoms_checker/risk_factors_screen.dart b/lib/presentation/symptoms_checker/risk_factors_screen.dart index 2992593..d4ff2cb 100644 --- a/lib/presentation/symptoms_checker/risk_factors_screen.dart +++ b/lib/presentation/symptoms_checker/risk_factors_screen.dart @@ -1,16 +1,15 @@ -import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.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/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/symptoms_checker/symptoms_checker_view_model.dart'; -import 'package:hmg_patient_app_new/generated/locale_keys.g.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/common_bottom_sheet.dart'; import 'package:provider/provider.dart'; class RiskFactorsScreen extends StatefulWidget { @@ -24,23 +23,24 @@ class _RiskFactorsScreenState extends State { @override void initState() { super.initState(); - // Initialize symptom groups based on selected organs + // Fetch risk factors based on selected symptoms WidgetsBinding.instance.addPostFrameCallback((_) { final viewModel = context.read(); - viewModel.initializeSymptomGroups(); + viewModel.fetchRiskFactors(); }); } - void _onOptionSelected(int optionIndex) {} + void _onRiskFactorSelected(SymptomsCheckerViewModel viewModel, String riskFactorId) { + viewModel.toggleRiskFactorSelection(riskFactorId); + } void _onNextPressed(SymptomsCheckerViewModel viewModel) { - if (viewModel.hasSelectedSymptoms) { - // Navigate to triage screen + if (viewModel.hasSelectedRiskFactors) { context.navigateWithName(AppRoutes.suggestionsScreen); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Please select at least one option before proceeding'.needTranslation), + content: Text('Please select at least one risk before proceeding'.needTranslation), backgroundColor: AppColors.errorColor, ), ); @@ -51,27 +51,13 @@ class _RiskFactorsScreenState extends State { context.pop(); } - _buildConfirmationBottomSheet({required BuildContext context, required VoidCallback onConfirm}) { - return showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: "Are you sure you want to restart the organ selection?".needTranslation, - isShowActionButtons: true, - onCancelTap: () => Navigator.pop(context), - onConfirmTap: () => onConfirm(), - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } + Widget _buildRiskFactorItem(SymptomsCheckerViewModel viewModel, String riskFactorId, String optionText) { + final bool selected = viewModel.isRiskFactorSelected(riskFactorId); - Widget _buildOptionItem(int index, bool selected, String optionText) { return GestureDetector( - onTap: () => _onOptionSelected(index), + onTap: () => _onRiskFactorSelected(viewModel, riskFactorId), child: Container( - margin: EdgeInsets.only(bottom: 12.h), + margin: EdgeInsets.only(bottom: 16.h), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -83,56 +69,137 @@ class _RiskFactorsScreenState extends State { decoration: BoxDecoration( color: selected ? AppColors.primaryRedColor : Colors.transparent, borderRadius: BorderRadius.circular(5.r), - border: Border.all(color: selected ? AppColors.primaryRedColor : AppColors.borderGrayColor, width: 1.w), + border: Border.all(color: selected ? AppColors.primaryRedColor : AppColors.checkBoxBorderColor, width: 1.w), ), child: selected ? Icon(Icons.check, size: 16.f, color: AppColors.whiteColor) : null, ), SizedBox(width: 12.w), Expanded( - child: Text( - optionText, - style: TextStyle(fontSize: 14.f, color: AppColors.textColor, fontWeight: FontWeight.w500), - ), - ), + child: optionText.toText14( + color: riskFactorId == "not_applicable" ? AppColors.errorColor : AppColors.textColor, + weight: FontWeight.w500, + )), ], ), ), ); } - Widget buildFactorsList() { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - transitionBuilder: (Widget child, Animation animation) { - final offsetAnimation = Tween( - begin: const Offset(1.0, 0.0), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: animation, - curve: Curves.easeInOut, - )); - - return SlideTransition( - position: offsetAnimation, - child: FadeTransition( - opacity: animation, - child: child, + Widget _buildRiskFactorsList(SymptomsCheckerViewModel viewModel) { + return Container( + key: ValueKey(viewModel.riskFactorsList.length), + width: double.infinity, + margin: EdgeInsets.symmetric(horizontal: 24.w), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r), + padding: EdgeInsets.only(top: 24.h, left: 16.w, right: 16.w, bottom: 8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...viewModel.riskFactorsList.map((factor) { + return _buildRiskFactorItem(viewModel, factor.id ?? '', factor.getDisplayName()); + }), + SizedBox(height: 12.w), + Row( + children: [ + Utils.buildSvgWithAssets( + icon: AppAssets.alertSquare, + height: 24.h, + width: 24.h, + iconColor: AppColors.textColor, + ), + SizedBox(width: 12.w), + Expanded( + child: RichText( + text: TextSpan( + style: TextStyle( + height: 1.3, + fontSize: 13.f, + fontWeight: FontWeight.w500, + color: AppColors.greyInfoTextColor, + ), + children: [ + TextSpan( + text: "Above you see the most common risk factors. Although /diagnosis may return questions about risk factors, " + .needTranslation, + ), + TextSpan( + text: "read more".needTranslation, + style: TextStyle( + color: AppColors.primaryRedColor, + fontWeight: FontWeight.w500, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + // handle tap - navigate or show bottom sheet + debugPrint('Read more tapped'); + // Example: Navigator.push(context, MaterialPageRoute(builder: (_) => RiskFactorsDetailScreen())); + }, + ), + ], + ), + ), + ) + ], ), - ); - }, - child: Container( - width: double.infinity, - margin: EdgeInsets.symmetric(horizontal: 24.w), - decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r), - padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 20.w), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...List.generate(4, (index) { - return _buildOptionItem(index, false, "currentQuestion.options[index].text"); + ], + ), + ); + } + + Widget _buildLoadingShimmer() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 16.h), + // Create 2-3 shimmer cards + ...List.generate(3, (index) { + return Padding( + padding: EdgeInsets.only(bottom: 16.h), + child: _buildShimmerCard(), + ); + }), + ], + ); + } + + Widget _buildShimmerCard() { + return Container( + width: double.infinity, + margin: EdgeInsets.symmetric(horizontal: 24.w), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r), + padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Shimmer title + Container( + height: 40.h, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24.r), + ), + ).toShimmer2(isShow: true, radius: 24.r), + SizedBox(height: 16.h), + // Shimmer chips + Wrap( + runSpacing: 12.h, + spacing: 8.w, + children: List.generate(4, (index) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(24.r), + border: Border.all(color: AppColors.bottomNAVBorder, width: 1), + ), + child: Text( + 'Not Applicable Risk Factor', + style: TextStyle(fontSize: 14.f, color: AppColors.textColor), + ), + ).toShimmer2(isShow: true, radius: 24.r); }), - ], - ), + ), + ], ), ); } @@ -147,68 +214,19 @@ class _RiskFactorsScreenState extends State { children: [ Expanded( child: CollapsingListView( - title: "Risks".needTranslation, - leadingCallback: () => _buildConfirmationBottomSheet( - context: context, - onConfirm: () => { - context.pop(), - context.pop(), - }), - child: _buildEmptyState(), - // child: viewModel.organSymptomsGroups.isEmpty - // ? _buildEmptyState() - // : Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // SizedBox(height: 16.h), - // ...viewModel.organSymptomsGroups.map((group) { - // return Padding( - // padding: EdgeInsets.only(bottom: 16.h), - // child: Container( - // width: double.infinity, - // margin: EdgeInsets.symmetric(horizontal: 24.w), - // decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r), - // padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 16.w), - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Row( - // mainAxisAlignment: MainAxisAlignment.spaceBetween, - // children: [ - // Expanded( - // child: Text( - // 'Possible symptoms related to "${group.organName}"', - // style: TextStyle(fontSize: 18.f, fontWeight: FontWeight.w600, color: AppColors.textColor), - // ), - // ), - // ], - // ), - // SizedBox(height: 24.h), - // Wrap( - // runSpacing: 12.h, - // spacing: 8.w, - // children: group.symptoms.map((symptom) { - // bool isSelected = viewModel.isSymptomSelected(group.organId, symptom.id); - // return GestureDetector( - // onTap: () => viewModel.toggleSymptomSelection(group.organId, symptom.id), - // child: CustomSelectableChip( - // label: symptom.name, - // selected: isSelected, - // activeColor: AppColors.primaryRedBorderColor, - // activeTextColor: AppColors.primaryRedBorderColor, - // inactiveBorderColor: AppColors.bottomNAVBorder, - // inactiveTextColor: AppColors.textColor, - // ), - // ); - // }).toList(), - // ), - // ], - // ), - // ), - // ); - // }), - // ], - // ), + title: "Risk Factors".needTranslation, + leadingCallback: () => context.pop(), + child: viewModel.isRiskFactorsLoading + ? _buildLoadingShimmer() + : viewModel.riskFactorsList.isEmpty + ? _buildEmptyState() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 16.h), + _buildRiskFactorsList(viewModel), + ], + ), ), ), _buildStickyBottomCard(context, viewModel), @@ -229,7 +247,7 @@ class _RiskFactorsScreenState extends State { Icon(Icons.info_outline, size: 64.h, color: AppColors.greyTextColor), SizedBox(height: 16.h), Text( - 'No organs selected'.needTranslation, + 'No risk factors found'.needTranslation, style: TextStyle( fontSize: 18.f, fontWeight: FontWeight.w600, @@ -238,7 +256,7 @@ class _RiskFactorsScreenState extends State { ), SizedBox(height: 8.h), Text( - 'Please go back and select organs first'.needTranslation, + 'Based on your selected symptoms, no additional risk factors were identified.'.needTranslation, textAlign: TextAlign.center, style: TextStyle( fontSize: 14.f, diff --git a/lib/presentation/symptoms_checker/suggestions_screen.dart b/lib/presentation/symptoms_checker/suggestions_screen.dart index 2832515..f2aa71e 100644 --- a/lib/presentation/symptoms_checker/suggestions_screen.dart +++ b/lib/presentation/symptoms_checker/suggestions_screen.dart @@ -1,16 +1,14 @@ -import 'package:easy_localization/easy_localization.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/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/symptoms_checker/symptoms_checker_view_model.dart'; -import 'package:hmg_patient_app_new/generated/locale_keys.g.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/common_bottom_sheet.dart'; import 'package:provider/provider.dart'; class SuggestionsScreen extends StatefulWidget { @@ -27,14 +25,18 @@ class _SuggestionsScreenState extends State { // Initialize symptom groups based on selected organs WidgetsBinding.instance.addPostFrameCallback((_) { final viewModel = context.read(); - viewModel.initializeSymptomGroups(); + viewModel.fetchSuggestions(); }); } void _onOptionSelected(int optionIndex) {} + void _onSuggestionSelected(SymptomsCheckerViewModel viewModel, String suggestionId) { + viewModel.toggleSuggestionsSelection(suggestionId); + } + void _onNextPressed(SymptomsCheckerViewModel viewModel) { - if (viewModel.hasSelectedSymptoms) { + if (viewModel.hasSelectedSuggestions) { // Navigate to triage screen context.navigateWithName(AppRoutes.triageScreen); } else { @@ -51,27 +53,13 @@ class _SuggestionsScreenState extends State { context.pop(); } - _buildConfirmationBottomSheet({required BuildContext context, required VoidCallback onConfirm}) { - return showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: "Are you sure you want to restart the organ selection?".needTranslation, - isShowActionButtons: true, - onCancelTap: () => Navigator.pop(context), - onConfirmTap: () => onConfirm(), - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } + Widget _buildSuggestionItem(SymptomsCheckerViewModel viewModel, String suggestionId, String optionText) { + final bool selected = viewModel.isSuggestionsSelected(suggestionId); - Widget _buildOptionItem(int index, bool selected, String optionText) { return GestureDetector( - onTap: () => _onOptionSelected(index), + onTap: () => _onSuggestionSelected(viewModel, suggestionId), child: Container( - margin: EdgeInsets.only(bottom: 12.h), + margin: EdgeInsets.only(bottom: 16.h), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -83,56 +71,111 @@ class _SuggestionsScreenState extends State { decoration: BoxDecoration( color: selected ? AppColors.primaryRedColor : Colors.transparent, borderRadius: BorderRadius.circular(5.r), - border: Border.all(color: selected ? AppColors.primaryRedColor : AppColors.borderGrayColor, width: 1.w), + border: Border.all(color: selected ? AppColors.primaryRedColor : AppColors.checkBoxBorderColor, width: 1.w), ), child: selected ? Icon(Icons.check, size: 16.f, color: AppColors.whiteColor) : null, ), SizedBox(width: 12.w), Expanded( - child: Text( - optionText, - style: TextStyle(fontSize: 14.f, color: AppColors.textColor, fontWeight: FontWeight.w500), - ), - ), + child: optionText.toText14( + color: suggestionId == "not_applicable" ? AppColors.errorColor : AppColors.textColor, + weight: FontWeight.w500, + )), ], ), ), ); } - Widget buildFactorsList() { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - transitionBuilder: (Widget child, Animation animation) { - final offsetAnimation = Tween( - begin: const Offset(1.0, 0.0), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: animation, - curve: Curves.easeInOut, - )); - - return SlideTransition( - position: offsetAnimation, - child: FadeTransition( - opacity: animation, - child: child, + Widget _buildSuggestionsList(SymptomsCheckerViewModel viewModel) { + return Container( + key: ValueKey(viewModel.suggestionsList.length), + width: double.infinity, + margin: EdgeInsets.symmetric(horizontal: 24.w), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r), + padding: EdgeInsets.only(top: 24.h, left: 16.w, right: 16.w, bottom: 8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...viewModel.suggestionsList.map((factor) { + return _buildSuggestionItem(viewModel, factor.id ?? '', factor.getDisplayName()); + }), + SizedBox(height: 12.w), + Row( + children: [ + Utils.buildSvgWithAssets( + icon: AppAssets.alertSquare, + height: 24.h, + width: 24.h, + iconColor: AppColors.textColor, + ), + SizedBox(width: 12.w), + Expanded( + child: "This is a list of symptoms suggested by our AI, based on the information gathered so far during the interview".toText12( + color: AppColors.greyInfoTextColor, + ), + ) + ], ), - ); - }, - child: Container( - width: double.infinity, - margin: EdgeInsets.symmetric(horizontal: 24.w), - decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r), - padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 20.w), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...List.generate(4, (index) { - return _buildOptionItem(index, false, "currentQuestion.options[index].text"); + ], + ), + ); + } + + Widget _buildLoadingShimmer() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 16.h), + // Create 2-3 shimmer cards + ...List.generate(3, (index) { + return Padding( + padding: EdgeInsets.only(bottom: 16.h), + child: _buildShimmerCard(), + ); + }), + ], + ); + } + + Widget _buildShimmerCard() { + return Container( + width: double.infinity, + margin: EdgeInsets.symmetric(horizontal: 24.w), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r), + padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Shimmer title + Container( + height: 40.h, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24.r), + ), + ).toShimmer2(isShow: true, radius: 24.r), + SizedBox(height: 16.h), + // Shimmer chips + Wrap( + runSpacing: 12.h, + spacing: 8.w, + children: List.generate(4, (index) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(24.r), + border: Border.all(color: AppColors.bottomNAVBorder, width: 1), + ), + child: Text( + 'Not Applicable Suggestion', + style: TextStyle(fontSize: 14.f, color: AppColors.textColor), + ), + ).toShimmer2(isShow: true, radius: 24.r); }), - ], - ), + ), + ], ), ); } @@ -148,68 +191,18 @@ class _SuggestionsScreenState extends State { Expanded( child: CollapsingListView( title: "Suggestions".needTranslation, - leadingCallback: () => _buildConfirmationBottomSheet( - context: context, - onConfirm: () => { - context.pop(), - context.pop(), - }), - child: _buildEmptyState(), - - // child: viewModel.organSymptomsGroups.isEmpty - // ? _buildEmptyState() - // : Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // SizedBox(height: 16.h), - // ...viewModel.organSymptomsGroups.map((group) { - // return Padding( - // padding: EdgeInsets.only(bottom: 16.h), - // child: Container( - // width: double.infinity, - // margin: EdgeInsets.symmetric(horizontal: 24.w), - // decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r), - // padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 16.w), - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Row( - // mainAxisAlignment: MainAxisAlignment.spaceBetween, - // children: [ - // Expanded( - // child: Text( - // 'Possible symptoms related to "${group.organName}"', - // style: TextStyle(fontSize: 18.f, fontWeight: FontWeight.w600, color: AppColors.textColor), - // ), - // ), - // ], - // ), - // SizedBox(height: 24.h), - // Wrap( - // runSpacing: 12.h, - // spacing: 8.w, - // children: group.symptoms.map((symptom) { - // bool isSelected = viewModel.isSymptomSelected(group.organId, symptom.id); - // return GestureDetector( - // onTap: () => viewModel.toggleSymptomSelection(group.organId, symptom.id), - // child: CustomSelectableChip( - // label: symptom.name, - // selected: isSelected, - // activeColor: AppColors.primaryRedBorderColor, - // activeTextColor: AppColors.primaryRedBorderColor, - // inactiveBorderColor: AppColors.bottomNAVBorder, - // inactiveTextColor: AppColors.textColor, - // ), - // ); - // }).toList(), - // ), - // ], - // ), - // ), - // ); - // }), - // ], - // ), + leadingCallback: () => context.pop(), + child: viewModel.isSuggestionsLoading + ? _buildLoadingShimmer() + : viewModel.suggestionsList.isEmpty + ? _buildEmptyState() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 16.h), + _buildSuggestionsList(viewModel), + ], + ), ), ), _buildStickyBottomCard(context, viewModel), diff --git a/lib/presentation/symptoms_checker/triage_screen.dart b/lib/presentation/symptoms_checker/triage_screen.dart index aa0cd72..ff0482e 100644 --- a/lib/presentation/symptoms_checker/triage_screen.dart +++ b/lib/presentation/symptoms_checker/triage_screen.dart @@ -148,10 +148,7 @@ class _TriageScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - currentQuestion.question, - style: TextStyle(fontSize: 16.f, fontWeight: FontWeight.w500, color: AppColors.textColor), - ), + currentQuestion.question.toText16(weight: FontWeight.w500), SizedBox(height: 24.h), ...List.generate(currentQuestion.options.length, (index) { bool selected = currentQuestion.selectedOptionIndex == index; @@ -179,17 +176,12 @@ class _TriageScreenState extends State { decoration: BoxDecoration( color: selected ? AppColors.primaryRedColor : Colors.transparent, borderRadius: BorderRadius.circular(5.r), - border: Border.all(color: selected ? AppColors.primaryRedColor : AppColors.borderGrayColor, width: 1.w), + border: Border.all(color: selected ? AppColors.primaryRedColor : AppColors.checkBoxBorderColor, width: 1.w), ), child: selected ? Icon(Icons.check, size: 16.f, color: AppColors.whiteColor) : null, ), SizedBox(width: 12.w), - Expanded( - child: Text( - optionText, - style: TextStyle(fontSize: 14.f, color: AppColors.textColor, fontWeight: FontWeight.w500), - ), - ), + Expanded(child: optionText.toText14(weight: FontWeight.w500)), ], ), ), diff --git a/lib/presentation/water_monitor/water_consumption_screen.dart b/lib/presentation/water_monitor/water_consumption_screen.dart new file mode 100644 index 0000000..198f65b --- /dev/null +++ b/lib/presentation/water_monitor/water_consumption_screen.dart @@ -0,0 +1,269 @@ +import 'dart:developer'; +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/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/water_consumption_progress_widget.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:provider/provider.dart'; + +class WaterConsumptionScreen extends StatefulWidget { + const WaterConsumptionScreen({super.key}); + + @override + State createState() => _WaterConsumptionScreenState(); +} + +class _WaterConsumptionScreenState extends State { + @override + void initState() { + super.initState(); + // Initialize VM once the widget is mounted + WidgetsBinding.instance.addPostFrameCallback((_) { + final vm = Provider.of(context, listen: false); + vm.initialize(); + // optional: log current progress for debugging + // ignore: avoid_print + log('WaterMonitor initialized: consumed=${vm.totalConsumedMl}, goal=${vm.dailyGoalMl}, progress=${vm.progressPercent}'); + }); + } + + _buildHydrationTipsWidget() { + return Container( + margin: EdgeInsets.symmetric(horizontal: 24.h), + 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), + ], + ), + ); + } + + _buildStatsColumn() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + "Next Drink Time".toText18(weight: FontWeight.w600, color: AppColors.textColor), + "03:00 AM".toText32(weight: FontWeight.w600, color: AppColors.blueColor), + SizedBox(height: 12.h), + _buildStatusColumn(title: "Your Goal".needTranslation, subTitle: "2000ml"), + SizedBox(height: 8.h), + _buildStatusColumn(title: "Remaining".needTranslation, subTitle: "300ml"), + SizedBox(height: 8.h), + _buildStatusColumn(title: "Completed".needTranslation, subTitle: "90%", subTitleColor: AppColors.successColor), + SizedBox(height: 8.h), + _buildStatusColumn(title: "Hydration Status".needTranslation, subTitle: "Hydrated", subTitleColor: AppColors.blueColor), + SizedBox(height: 8.h), + ], + ); + } + + _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: LayoutBuilder( + builder: (context, constraints) { + // Responsive: stack vertically on small screens + final isSmallScreen = constraints.maxWidth < 600; + + if (isSmallScreen) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(flex: 3, child: _buildStatsColumn()), + SizedBox(width: 16.w), + Expanded(flex: 2, child: _buildWaterBottleWidget()), + ], + ); + } else { + // Larger screens: more space for bottle + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible(flex: 2, child: _buildStatsColumn()), + SizedBox(width: 32.w), + Flexible(flex: 1, child: _buildWaterBottleWidget()), + ], + ); + } + }, + ), + ); + } + + _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 _buildWaterBottleWidget() { + return Consumer( + builder: (context, vm, _) { + // final progressPercent = 100.0; + final progressPercent = (vm.progress * 100).clamp(0.0, 100.0); + log("progressPercent: ${progressPercent}"); + // SVG original dimensions + final svgOriginalWidth = 143.w; + final svgOriginalHeight = 315.h; + + // Responsive sizing - scale based on screen width but maintain aspect ratio + final bottleWidth = 120.w; // Adjust this for your layout + final aspectRatio = svgOriginalHeight / svgOriginalWidth; // 2. 2027 + final bottleHeight = bottleWidth * aspectRatio; + + // Exact fillable area based on SVG paths (provided by VM calibration) + final fillableHeightPercent = vm.fillableHeightPercent; + final fillableWidthPercent = 0.8; // 80.7% is body width + final bottomPaddingPercent = 0; // 0% padding from bottom (8px of 315px) + + final fillableHeight = bottleHeight * fillableHeightPercent; + final fillableWidth = bottleWidth * fillableWidthPercent; + final bottomPadding = bottleHeight * bottomPaddingPercent; + return SizedBox( + height: bottleHeight, + width: bottleWidth, + child: Stack( + alignment: Alignment.center, + children: [ + // Bottle SVG + Center( + child: Utils.buildSvgWithAssets( + icon: AppAssets.waterBottle, + height: bottleHeight, + width: bottleWidth, + fit: BoxFit.contain, // Important for scaling + ), + ), + + // Water wave - positioned from bottom + Positioned( + bottom: bottomPadding, + right: 10.w, + child: ClipPath( + clipper: BottleShapeClipper(), // Use custom clipper for exact bottle shape + child: SizedBox( + height: fillableHeight, + width: fillableWidth, + child: Align( + alignment: Alignment.bottomCenter, + child: WaterConsumptionProgressWidget( + progress: progressPercent, + // Pass a square size so the painter's vertical range matches the clip area. + // Use the larger of width/height so the waves render correctly and won't be squashed. + size: math.max(fillableWidth, fillableHeight), + // Provide exact container dimensions so the painter draws waves using the bottle's rect + containerWidth: fillableWidth, + containerHeight: fillableHeight, + waveDuration: const Duration(milliseconds: 3000), + waveColor: AppColors.blueColor, + ), + ), + ), + ), + ), + + // Bubbles positioned dynamically based on water level + if (progressPercent > 10) + Positioned( + bottom: 40.h, + child: Utils.buildSvgWithAssets( + icon: AppAssets.waterBottleOuterBubbles, + height: 60.h, + width: fillableWidth * 0.7, + ), + ), + ], + ), + ); + }, + ); + } + + @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), + ), + ), + ), + child: Column( + children: [ + SizedBox(height: 16.h), + _buildWaterIntakeSummaryWidget(), + SizedBox(height: 16.h), + _buildHydrationTipsWidget(), + ], + ), + ), + ); + // ); + } +} diff --git a/lib/presentation/water_monitor/water_monitor_settings_screen.dart b/lib/presentation/water_monitor/water_monitor_settings_screen.dart new file mode 100644 index 0000000..e49c8a3 --- /dev/null +++ b/lib/presentation/water_monitor/water_monitor_settings_screen.dart @@ -0,0 +1,360 @@ +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/dependencies.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/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/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:provider/provider.dart'; + +class WaterMonitorSettingsScreen extends StatefulWidget { + const WaterMonitorSettingsScreen({super.key}); + + @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), + backgroundColor: AppColors.errorColor, + ), + ); + } + + // Reusable method to build selection row widget + Widget _buildSelectionRow({ + required String value, + required String groupValue, + required VoidCallback onTap, + bool useUpperCase = false, + }) { + return SizedBox( + height: 70.h, + child: Row( + spacing: 8.h, + children: [ + Radio( + value: value, + groupValue: groupValue, + activeColor: AppColors.errorColor, + onChanged: (_) => onTap(), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + (useUpperCase ? value.toUpperCase() : value.toCamelCase) + .toText16(weight: FontWeight.w500, textOverflow: TextOverflow.ellipsis, maxlines: 1) + .expanded, + ], + ).onPress(onTap), + ); + } + + // Reusable method to show selection bottom sheet + void _showSelectionBottomSheet({ + required BuildContext context, + required String title, + required List items, + required String selectedValue, + required Function(String) onSelected, + bool useUpperCase = false, + }) { + final dialogService = getIt.get(); + + dialogService.showFamilyBottomSheetWithoutHWithChild( + label: title.needTranslation, + 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)), + child: ListView.separated( + shrinkWrap: true, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return _buildSelectionRow( + value: item, + groupValue: selectedValue, + useUpperCase: useUpperCase, + onTap: () { + onSelected(item); + Navigator.pop(context); + }, + ); + }, + separatorBuilder: (_, __) => Divider(height: 1, color: AppColors.dividerColor), + ), + ), + onOkPressed: () {}, + ); + } + + void _showGenderSelectionBottomsheet(BuildContext context, WaterMonitorViewModel viewModel) { + _showSelectionBottomSheet( + context: context, + title: "Select Gender".needTranslation, + items: viewModel.genderOptions, + selectedValue: viewModel.selectedGender, + onSelected: viewModel.setGender, + ); + } + + void _showHeightUnitSelectionBottomSheet(BuildContext context, WaterMonitorViewModel viewModel) { + _showSelectionBottomSheet( + context: context, + title: "Select Unit".needTranslation, + items: viewModel.heightUnits, + selectedValue: viewModel.selectedHeightUnit, + onSelected: viewModel.setHeightUnit, + useUpperCase: true, + ); + } + + void _showWeightUnitSelectionBottomsheet(BuildContext context, WaterMonitorViewModel viewModel) { + _showSelectionBottomSheet( + context: context, + title: "Select Unit".needTranslation, + items: viewModel.weightUnits, + selectedValue: viewModel.selectedWeightUnit, + onSelected: viewModel.setWeightUnit, + useUpperCase: true, + ); + } + + void _showActivityLevelSelectionBottomsheet(BuildContext context, WaterMonitorViewModel viewModel) { + _showSelectionBottomSheet( + context: context, + title: "Select Activity Level".needTranslation, + items: viewModel.activityLevels, + selectedValue: viewModel.selectedActivityLevel, + onSelected: viewModel.setActivityLevel, + ); + } + + void _showNumberOfRemindersSelectionBottomsheet(BuildContext context, WaterMonitorViewModel viewModel) { + _showSelectionBottomSheet( + context: context, + title: "Select Number of Reminders".needTranslation, + items: viewModel.reminderOptions, + selectedValue: viewModel.selectedNumberOfReminders, + onSelected: viewModel.setNumberOfReminders, + ); + } + + // Reusable method to build text field + Widget _buildTextField(TextEditingController controller, String hintText) { + return TextField( + controller: controller, + keyboardType: TextInputType.number, + maxLines: 1, + cursorHeight: 14.h, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isCollapsed: true, + hintText: hintText, + hintStyle: const TextStyle(color: Colors.grey), + ), + style: TextStyle( + fontSize: 14.f, + fontWeight: FontWeight.w500, + color: AppColors.textColor, + ), + ); + } + + // Reusable method to build settings row + Widget _buildSettingsRow({ + required String icon, + required String label, + String? value, + Widget? inputField, + String? unit, + VoidCallback? onUnitTap, + VoidCallback? onRowTap, + bool showDivider = true, + }) { + return Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 40.w, + width: 40.w, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.greyColor, + borderRadius: 10.r, + hasShadow: false, + ), + child: Center(child: Utils.buildSvgWithAssets(icon: icon, height: 22.w, width: 22.w)), + ), + SizedBox(width: 12.w), + Expanded( + flex: unit != null ? 3 : 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + label.toText12(fontWeight: FontWeight.w500, color: AppColors.inputLabelTextColor), + if (inputField != null) + Container( + height: 20.w, + alignment: Alignment.centerLeft, + child: inputField, + ) + else if (value != null) + value.toCamelCase.toText12(fontWeight: FontWeight.w500, color: AppColors.textColor), + ], + ), + ), + if (unit != null) ...[ + Container( + width: 1.w, + height: 30.w, + color: AppColors.dividerColor, + ).paddingOnly(right: 10.w), + Expanded( + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + "Unit".toText12(fontWeight: FontWeight.w500, color: AppColors.inputLabelTextColor), + unit.toText12(fontWeight: FontWeight.w500, color: AppColors.textColor), + ], + ), + SizedBox(width: 12.w), + Utils.buildSvgWithAssets(icon: AppAssets.arrow_down) + ], + ).onPress(onUnitTap ?? () {}), + ), + ] else if (onRowTap != null) ...[ + Utils.buildSvgWithAssets(icon: AppAssets.arrow_down), + SizedBox(width: 4.w), + ], + ], + ).paddingSymmetrical(0.w, 16.w).onPress(onRowTap ?? () {}), + if (showDivider) Divider(height: 1, color: AppColors.dividerColor), + ], + ); + } + + @override + Widget build(BuildContext context) { + final viewModel = context.watch(); + + return Scaffold( + backgroundColor: AppColors.bgScaffoldColor, + body: CollapsingListView( + title: "H20 Settings".needTranslation, + bottomChild: Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.r, + hasShadow: true, + ), + child: Padding( + padding: EdgeInsets.all(24.w), + child: CustomButton( + text: "Save".needTranslation, + onPressed: () async { + showSwitchCupBottomSheet(context); + return; + final success = await viewModel.saveSettings(); + if (!success && viewModel.validationError != null) { + _showSnackBar(context, viewModel.validationError!); + } else if (success) { + _showSnackBar(context, "Saved Successfully".needTranslation); + } + }, + borderRadius: 12.r, + padding: EdgeInsets.symmetric(vertical: 14.h), + ), + ), + ), + child: Container( + margin: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h), + padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 4.h, bottom: 4.h), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r, hasShadow: true), + child: Column( + children: [ + _buildSettingsRow( + icon: AppAssets.profileIcon, + label: "Your Name".needTranslation, + inputField: _buildTextField(viewModel.nameController, 'Guest'), + ), + _buildSettingsRow( + icon: AppAssets.genderIcon, + label: "Select Gender".needTranslation, + value: viewModel.selectedGender, + onRowTap: () => _showGenderSelectionBottomsheet(context, viewModel), + ), + _buildSettingsRow( + icon: AppAssets.calendarGrey, + label: "Age (11-120) yrs".needTranslation, + inputField: _buildTextField(viewModel.ageController, '20'), + ), + _buildSettingsRow( + icon: AppAssets.heightIcon, + label: "Height".needTranslation, + inputField: _buildTextField(viewModel.heightController, '175'), + unit: viewModel.selectedHeightUnit, + onUnitTap: () => _showHeightUnitSelectionBottomSheet(context, viewModel), + ), + _buildSettingsRow( + icon: AppAssets.weightScaleIcon, + label: "Weight".needTranslation, + inputField: _buildTextField(viewModel.weightController, '75'), + unit: viewModel.selectedWeightUnit, + onUnitTap: () => _showWeightUnitSelectionBottomsheet(context, viewModel), + ), + _buildSettingsRow( + icon: AppAssets.dumbellIcon, + label: "Activity Level".needTranslation, + value: viewModel.selectedActivityLevel, + onRowTap: () => _showActivityLevelSelectionBottomsheet(context, viewModel), + ), + _buildSettingsRow( + icon: AppAssets.notificationIconGrey, + label: "Number of reminders in a day".needTranslation, + value: viewModel.selectedNumberOfReminders, + onRowTap: () => _showNumberOfRemindersSelectionBottomsheet(context, viewModel), + showDivider: false, + ), + ], + ), + ), + ), + ); + } + + // Show snackbar for validation errors and success messages + void _showSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 3), + behavior: SnackBarBehavior.floating, + backgroundColor: message.contains('successfully') ? Colors.green : AppColors.errorColor, + ), + ); + } +} diff --git a/lib/presentation/water_monitor/widgets/bottle_shape_clipper.dart b/lib/presentation/water_monitor/widgets/bottle_shape_clipper.dart new file mode 100644 index 0000000..b4b7ba0 --- /dev/null +++ b/lib/presentation/water_monitor/widgets/bottle_shape_clipper.dart @@ -0,0 +1,25 @@ +// Add this class at the bottom of your file (outside the main class) +import 'package:flutter/material.dart'; + +class BottleShapeClipper extends CustomClipper { + @override + Path getClip(Size size) { + final path = Path(); + + // Create rounded rectangle matching the bottle body shape + // The bottle has rounded corners with radius ~30-40 based on SVG + final borderRadius = size.width * 0.25; // 25% of width for rounded corners + + path.addRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + Radius.circular(borderRadius), + ), + ); + + return path; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} diff --git a/lib/presentation/water_monitor/widgets/cup_bottomsheet_widgets.dart b/lib/presentation/water_monitor/widgets/cup_bottomsheet_widgets.dart new file mode 100644 index 0000000..013ac7b --- /dev/null +++ b/lib/presentation/water_monitor/widgets/cup_bottomsheet_widgets.dart @@ -0,0 +1,324 @@ +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/models/water_cup_model.dart'; +import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; +import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; + +/// Bottom sheet to switch between existing cups or add new custom cup +void showSwitchCupBottomSheet(BuildContext context) { + return showCommonBottomSheetWithoutHeight( + context, + titleWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + "Switch Cups".toText20(weight: FontWeight.w600), + "Select your preferred cup size".toText16(weight: FontWeight.w500, color: AppColors.greyTextColor), + ], + ), + child: SwitchCupBottomSheet(), + callBackFunc: () {}, + ); +} + +class SwitchCupBottomSheet extends StatelessWidget { + const SwitchCupBottomSheet({super.key}); + + @override + Widget build(BuildContext context) { + final viewModel = context.watch(); + final selectedId = viewModel.selectedCup?.id; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 16.w, + childAspectRatio: 0.85, + ), + itemCount: viewModel.cups.length + 1, + itemBuilder: (context, index) { + if (index == viewModel.cups.length) { + return _buildAddCupItem(context); + } + + final cup = viewModel.cups[index]; + final isSelected = selectedId == cup.id; + + return _buildCupItem( + cup: cup, + isSelected: isSelected, + onTap: () { + viewModel.selectCup(cup.id); + context.pop(); + }, + ); + }, + ), + ], + ); + } + + Widget _buildCupItem({required WaterCupModel cup, required bool isSelected, required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 60.w, + height: 60.w, + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryRedColor.withOpacity(0.08) : Colors.transparent, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isSelected ? AppColors.primaryRedColor : AppColors.bgScaffoldColor, + width: 1, + ), + ), + child: Center( + child: Utils.buildSvgWithAssets( + icon: cup.iconPath, + height: 30.h, + width: 42.w, + iconColor: isSelected ? AppColors.primaryRedColor : null, + ), + ), + ), + // Red badge for custom cups (delete) + if (!cup.isDefault) + Positioned( + top: -6.h, + right: -6.w, + child: Builder(builder: (ctx) { + return InkWell( + onTap: () { + // call viewmodel remove + final vm = ctx.read(); + vm.removeCup(cup.id); + }, + child: Container( + color: AppColors.whiteColor, + child: Utils.buildSvgWithAssets(icon: AppAssets.minimizeIcon, height: 20.w, width: 20.w), + ), + ); + }), + ), + ], + ), + SizedBox(height: 2.h), + '${cup.capacityMl}ml'.toText10(weight: FontWeight.w500), + ], + ), + ); + } + + Widget _buildAddCupItem(BuildContext context) { + return GestureDetector( + onTap: () { + showCustomizeCupBottomSheet(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 60.w, + height: 60.w, + child: Center(child: Utils.buildSvgWithAssets(icon: AppAssets.cupAdd, height: 30.h, width: 42.w)), + ), + SizedBox(height: 4.h), + 'Add'.needTranslation.toText10(weight: FontWeight.w500), + ], + ), + ); + } +} + +/// Bottom sheet to customize cup capacity with slider +void showCustomizeCupBottomSheet(BuildContext context, {WaterCupModel? cupToEdit}) { + return showCommonBottomSheetWithoutHeight( + context, + titleWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + "Customize your drink cup".needTranslation.toText20(weight: FontWeight.w600), + ], + ), + child: CustomizeCupBottomSheet(cupToEdit: cupToEdit), + callBackFunc: () {}, + ); +} + +class CustomizeCupBottomSheet extends StatefulWidget { + final WaterCupModel? cupToEdit; + + const CustomizeCupBottomSheet({super.key, this.cupToEdit}); + + @override + State createState() => _CustomizeCupBottomSheetState(); +} + +class _CustomizeCupBottomSheetState extends State { + static const int minCapacity = 50; + static const int maxCapacity = 500; + + late double _currentCapacity; + + @override + void initState() { + super.initState(); + _currentCapacity = (widget.cupToEdit?.capacityMl ?? 150).toDouble(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cup icon with fill level visualization + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 60.w, + height: 80.h, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + // Cup image with dynamic fill + SizedBox( + width: 60.w, + height: 80.h, + child: Center( + child: Utils.buildSvgWithAssets( + icon: AppAssets.cupEmpty, + width: 60.w, + height: 80.h, + fit: BoxFit.contain, + ), + ), + ), + ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: (_currentCapacity / maxCapacity).clamp(0.0, 1.0), + child: Utils.buildSvgWithAssets( + icon: AppAssets.cupFilled, + width: 60.w, + height: 80.h, + fit: BoxFit.contain, + )), + ), + ], + ), + ), + + SizedBox(width: 12.w), + + // Slider and labels + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Current value + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + '${_currentCapacity.round()}'.toText32(isBold: true), + SizedBox(width: 4.w), + Padding( + padding: EdgeInsets.only(bottom: 4.h), + child: Text( + 'ml', + style: TextStyle( + fontSize: 16.f, + fontWeight: FontWeight.w500, + color: AppColors.greyTextColor, + height: 1.0, + ), + ), + ), + ], + ).paddingOnly(left: 12.w), + + SizedBox(height: 16.h), + + // Slider + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: AppColors.primaryRedColor, + inactiveTrackColor: AppColors.primaryRedColor.withOpacity(0.2), + thumbColor: AppColors.primaryRedColor, + overlayColor: AppColors.primaryRedColor.withOpacity(0.2), + trackHeight: 4.h, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 10.w), + ), + child: Slider( + value: _currentCapacity, + min: minCapacity.toDouble(), + max: maxCapacity.toDouble(), + divisions: (maxCapacity - minCapacity) ~/ 10, + onChanged: (value) => setState(() => _currentCapacity = value), + ), + ), + + Align( + alignment: Alignment.centerRight, + child: 'Max: $maxCapacity ml'.toText14( + color: AppColors.greyTextColor, + ), + ), + ], + ), + ), + ], + ), + + SizedBox(height: 24.h), + CustomButton( + text: 'Select'.needTranslation, + onPressed: () { + final newCup = WaterCupModel( + id: widget.cupToEdit?.id ?? Uuid().v4(), + name: '${_currentCapacity.round()}ml', + capacityMl: _currentCapacity.round(), + iconPath: AppAssets.cupEmpty, + isDefault: false, + ); + + final viewModel = context.read(); + if (widget.cupToEdit != null) { + viewModel.updateCup(newCup); + } else { + viewModel.addCup(newCup); + } + viewModel.selectCup(newCup.id); + + Navigator.pop(context); + }, + backgroundColor: AppColors.primaryRedColor, + textColor: AppColors.whiteColor, + fontSize: 16.f, + ), + ], + ); + } +} diff --git a/lib/presentation/water_monitor/widgets/water_consumption_progress_widget.dart b/lib/presentation/water_monitor/widgets/water_consumption_progress_widget.dart new file mode 100644 index 0000000..993d7ce --- /dev/null +++ b/lib/presentation/water_monitor/widgets/water_consumption_progress_widget.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/widgets/water_splash_progress_widget.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; + +class WaterConsumptionProgressWidget extends StatefulWidget { + /// progress: 0.0 - 100.0 + final double progress; + final double size; + final Color? waveColor; + final double? containerWidth; + final double? containerHeight; + final Duration? waveDuration; + + const WaterConsumptionProgressWidget({ + super.key, + required this.progress, + this.size = 100, + this.waveColor, + this.containerWidth, + this.containerHeight, + this.waveDuration, + }); + + @override + State createState() => _WaterConsumptionProgressWidgetState(); +} + +class _WaterConsumptionProgressWidgetState extends State with SingleTickerProviderStateMixin { + late AnimationController _progressController; + late Animation _progressAnimation; + double _lastTarget = 0.0; + + @override + void initState() { + super.initState(); + _lastTarget = widget.progress; + _progressController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200)); + _progressAnimation = Tween(begin: 0, end: widget.progress).animate(CurvedAnimation(parent: _progressController, curve: Curves.easeInOut)); + _progressController.forward(); + } + + @override + void didUpdateWidget(covariant WaterConsumptionProgressWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if ((widget.progress - _lastTarget).abs() > 0.01) { + // animate from current value to new target + final begin = _progressAnimation.value; + _progressAnimation = + Tween(begin: begin, end: widget.progress).animate(CurvedAnimation(parent: _progressController, curve: Curves.easeInOut)); + _progressController + ..reset() + ..forward(); + _lastTarget = widget.progress; + } + } + + @override + void dispose() { + _progressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // default to app blue color if none provided + final blue = widget.waveColor ?? AppColors.blueColor; + return AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + return WaterWaveProgress( + progress: _progressAnimation.value, + size: widget.size, + showPercentage: false, + useCircleClip: false, + waveColor: blue, + containerWidth: widget.containerWidth, + containerHeight: widget.containerHeight, + waveDuration: widget.waveDuration, + backgroundColor: Colors.transparent, + progressColor: Colors.transparent, + textColor: Colors.transparent, + ); + }, + ); + } +} diff --git a/lib/presentation/water_monitor/widgets/water_splash_progress_widget.dart b/lib/presentation/water_monitor/widgets/water_splash_progress_widget.dart new file mode 100644 index 0000000..da5eb06 --- /dev/null +++ b/lib/presentation/water_monitor/widgets/water_splash_progress_widget.dart @@ -0,0 +1,351 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; + +/// Spherical water wave progress bar with animated ripple effect +/// Based on https://github.com/meeziest/spherical_water_wavy_progress_bar +/// +/// Usage: +/// WaterWaveProgress( +/// progress: 75.0, +/// size: 200.w, +/// showPercentage: true, +/// ) +class WaterWaveProgress extends StatefulWidget { + final double progress; // 0.0 to 100.0 + final double size; + final bool showPercentage; + final bool useCircleClip; + final Color? waveColor; + final Color? backgroundColor; + final Color? progressColor; + final Color? textColor; + final Duration? waveDuration; + + // When drawing inside a non-square clip (like bottle area), provide the actual available + // width and height so the painter can compute vertical fill correctly. + final double? containerWidth; + final double? containerHeight; + + const WaterWaveProgress({ + super.key, + required this.progress, + this.size = 200, + this.showPercentage = true, + this.useCircleClip = true, + this.waveColor, + this.backgroundColor, + this.progressColor, + this.textColor, + this.waveDuration, + this.containerWidth, + this.containerHeight, + }); + + @override + State createState() => _WaterWaveProgressState(); +} + +class _WaterWaveProgressState extends State with SingleTickerProviderStateMixin { + late AnimationController _waveController; + + @override + void initState() { + super.initState(); + _waveController = AnimationController( + vsync: this, + duration: widget.waveDuration ?? const Duration(milliseconds: 2000), + )..repeat(); + } + + @override + void dispose() { + _waveController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final radius = widget.size / 2; + + // When container dimensions are provided (non-square bottle area), use them so + // the CustomPaint canvas matches the available clip rect. Otherwise fall back + // to the square size value. + final paintWidth = widget.containerWidth ?? widget.size; + final paintHeight = widget.containerHeight ?? widget.size; + + return SizedBox( + width: paintWidth, + height: paintHeight, + child: CustomPaint( + painter: _WavePainter( + progress: widget.progress, + waveAnimation: _waveController, + // For circle-based painters we still provide circleRadius; when using + // rectangular painting the circleRadius is unused. + circleRadius: radius, + waveColor: widget.waveColor ?? AppColors.primaryRedColor, + backgroundColor: widget.backgroundColor ?? AppColors.bgScaffoldColor, + useCircleClip: widget.useCircleClip, + containerWidth: widget.containerWidth, + containerHeight: widget.containerHeight, + ), + // Only draw the circular progress/percentage when explicitly requested. + foregroundPainter: widget.showPercentage + ? _ProgressPainter( + progress: widget.progress, + circleRadius: radius, + progressColor: widget.progressColor ?? AppColors.primaryRedColor, + textColor: widget.textColor ?? AppColors.textColor, + showPercentage: widget.showPercentage, + ) + : null, + ), + ); + } +} + +/// Paints the animated water waves inside a circular clip +class _WavePainter extends CustomPainter { + final double progress; + final Animation waveAnimation; + final double circleRadius; + final Color waveColor; + final Color backgroundColor; + final bool useCircleClip; + final double? containerWidth; + final double? containerHeight; + + _WavePainter({ + required this.progress, + required this.waveAnimation, + required this.circleRadius, + required this.waveColor, + required this.backgroundColor, + this.useCircleClip = true, + this.containerWidth, + this.containerHeight, + }) : super(repaint: waveAnimation); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + canvas.translate(center.dx, center.dy); + + if (useCircleClip) { + // Clip to circle + canvas.clipPath( + Path() + ..addOval( + Rect.fromCircle( + center: Offset.zero, + radius: circleRadius, + ), + ), + ); + + // Fill background circle if specified + if (backgroundColor != Colors.transparent) { + canvas.drawCircle( + Offset.zero, + circleRadius, + Paint()..color = backgroundColor, + ); + } + + // Draw two sine waves clipped to circle for spherical style + _drawSineWave(canvas, waveColor.withAlpha((0.5 * 255).round()), shift: 0); + _drawSineWave(canvas, waveColor, shift: circleRadius / 2, mirror: true); + } else { + // No circular clipping: draw waves over the full rectangular area. Use provided container + // dimensions if available; otherwise fall back to square bounds derived from circleRadius. + final w = containerWidth ?? (circleRadius * 2); + final h = containerHeight ?? (circleRadius * 2); + + // use local transforms to center at origin and compute waves relative to the provided rect + canvas.save(); + canvas.translate(-w / 2, -h / 2); // move origin to top-left of the rect + + _drawSineWaveRect(canvas, waveColor.withAlpha((0.5 * 255).round()), width: w, height: h, shift: 0); + _drawSineWaveRect(canvas, waveColor, width: w, height: h, shift: w / 4, mirror: true); + + canvas.restore(); + } + } + + void _drawSineWaveRect(Canvas canvas, Color color, {required double width, required double height, double shift = 0.0, bool mirror = false}) { + if (mirror) { + canvas.save(); + canvas.translate(width, 0); + canvas.scale(-1, 1); + } + + final amplitude = height * 0.04; // smaller amplitude for rectangular waves + final angularVelocity = pi / (width / 2); + final delta = Curves.easeInOut.transform(progress / 100); + + final offsetX = 2 * width * waveAnimation.value + shift; + final offsetY = height * (1.0 - delta); + + final path = Path(); + for (double x = 0; x <= width; x += 1) { + final y = amplitude * sin(angularVelocity * (x + offsetX)); + if (x == 0) { + path.moveTo(x, y + offsetY); + } else { + path.lineTo(x, y + offsetY); + } + } + + path.lineTo(width, height); + path.lineTo(0, height); + path.close(); + + final wavePaint = Paint() + ..color = color + ..style = PaintingStyle.fill + ..isAntiAlias = true; + + canvas.drawPath(path, wavePaint); + + if (mirror) canvas.restore(); + } + + void _drawSineWave(Canvas canvas, Color color, {double shift = 0.0, bool mirror = false}) { + if (mirror) { + canvas.save(); + canvas.transform(Matrix4.rotationY(pi).storage); + } + + // original circular/spherical style: compute bounds based on circleRadius + final startX = -circleRadius; + final endX = circleRadius; + final startY = circleRadius; + final endY = -circleRadius; + + final amplitude = circleRadius * 0.15; + final angularVelocity = pi / circleRadius; + final delta = Curves.easeInOut.transform(progress / 100); + + final offsetX = 2 * circleRadius * waveAnimation.value + shift; + final offsetY = startY + (endY - startY - amplitude) * delta; + + final wavePaint = Paint() + ..color = color + ..style = PaintingStyle.fill + ..isAntiAlias = true; + + final path = Path(); + + for (double x = startX; x <= endX; x++) { + // Sine wave function: y = A * sin(ωx + φ) + final y = amplitude * sin(angularVelocity * (x + offsetX)); + if (x == startX) { + path.moveTo(x, y + offsetY); + } else { + path.lineTo(x, y + offsetY); + } + } + + path.lineTo(endX, startY); + path.lineTo(startX, startY); + path.close(); + + canvas.drawPath(path, wavePaint); + + if (mirror) canvas.restore(); + } + + @override + bool shouldRepaint(covariant _WavePainter oldDelegate) => oldDelegate.progress != progress; +} + +/// Paints the circular progress arc and percentage text +class _ProgressPainter extends CustomPainter { + final double progress; + final double circleRadius; + final Color progressColor; + final Color textColor; + final bool showPercentage; + + _ProgressPainter({ + required this.progress, + required this.circleRadius, + required this.progressColor, + required this.textColor, + required this.showPercentage, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + canvas.translate(center.dx, center.dy); + + _drawCircleProgress(canvas); + if (showPercentage) { + _drawProgressText(canvas); + } + } + + void _drawCircleProgress(Canvas canvas) { + final strokeWidth = circleRadius * 0.077; + + // Background circle with shadow + final bgPaint = Paint() + ..color = progressColor.withAlpha((0.2 * 255).round()) + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..isAntiAlias = true; + + final shadowPaint = Paint() + ..color = Colors.black.withAlpha((0.1 * 255).round()) + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..maskFilter = const MaskFilter.blur(BlurStyle.outer, 10); + + canvas.drawCircle(Offset.zero, circleRadius, bgPaint); + canvas.drawCircle(Offset.zero, circleRadius, shadowPaint); + + // Progress arc + final progressPaint = Paint() + ..color = progressColor + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke + ..isAntiAlias = true; + + canvas.drawArc( + Rect.fromCircle(center: Offset.zero, radius: circleRadius), + -0.5 * pi, + 2 * pi * (progress / 100), + false, + progressPaint, + ); + } + + void _drawProgressText(Canvas canvas) { + final textSpan = TextSpan( + text: "${progress.toInt()}%", + style: TextStyle( + color: textColor, + fontSize: circleRadius * 0.35, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ); + + final textPainter = TextPainter( + text: textSpan, + textDirection: TextDirection.ltr, + )..layout(); + + textPainter.paint( + canvas, + Offset(-textPainter.width / 2, -textPainter.height / 2), + ); + } + + @override + bool shouldRepaint(covariant _ProgressPainter oldDelegate) => oldDelegate.progress != progress; +} diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index d25a9de..5ca17c5 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -21,6 +21,8 @@ import 'package:hmg_patient_app_new/presentation/symptoms_checker/triage_screen. import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection.dart'; import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/user_info_flow_manager.dart'; import 'package:hmg_patient_app_new/presentation/tele_consultation/zoom/call_screen.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/water_consumption_screen.dart'; +import 'package:hmg_patient_app_new/presentation/water_monitor/water_monitor_settings_screen.dart'; import 'package:hmg_patient_app_new/splashPage.dart'; class AppRoutes { @@ -42,6 +44,10 @@ class AppRoutes { //appointments static const String bookAppointmentPage = '/bookAppointmentPage'; + // Water Monitor + static const String waterConsumptionScreen = '/waterConsumptionScreen'; + static const String waterMonitorSettingsScreen = '/waterMonitorSettingsScreen'; + // Symptoms Checker static const String organSelectorPage = '/organSelectorPage'; static const String symptomsSelectorScreen = '/symptomsCheckerScreen'; @@ -77,9 +83,8 @@ class AppRoutes { userInfoFlowManager: (context) => UserInfoFlowManager(), smartWatches: (context) => SmartwatchInstructionsPage(), huaweiHealthExample: (context) => HuaweiHealthExample(), - - // - healthCalculatorsPage: (context) => HealthCalculatorsPage() - + healthCalculatorsPage: (context) => HealthCalculatorsPage(), + waterConsumptionScreen: (context) => WaterConsumptionScreen(), + waterMonitorSettingsScreen: (context) => WaterMonitorSettingsScreen(), }; } diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index d0db6a5..4494ce4 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -8,6 +8,7 @@ class AppColors { static const bottomSheetBgColor = Color(0xFFF8F8FA); static const lightGreyEFColor = Color(0xffeaeaff); static const greyF7Color = Color(0xffF7F7F7); + static const greyInfoTextColor = Color(0xff777777); static const lightGrayColor = Color(0xff808080); static const greyTextColorLight = Color(0xFFA2A2A2); @@ -32,6 +33,7 @@ class AppColors { static const Color inputLabelTextColor = Color(0xff898A8D); static const Color greyTextColor = Color(0xFF8F9AA3); static const Color lightGrayBGColor = Color(0x142E3039); + static const Color checkBoxBorderColor = Color(0xffD2D2D2); static const Color pharmacyBGColor = Color(0xFF359846); @@ -42,6 +44,7 @@ class AppColors { //Chips static const Color successColor = Color(0xff18C273); + static const Color successLightBgColor = Color(0xffDDF6EA); static const Color errorColor = Color(0xFFED1C2B); static const Color alertColor = Color(0xFFD48D05); static const Color infoColor = Color(0xFF0B85F7); @@ -96,4 +99,9 @@ class AppColors { static const Color eReferralCardColor = Color(0xFFFF8012); static const Color bloodDonationCardColor = Color(0xFFFF5662); static const Color bookAppointment = Color(0xFF415364); + + // Water Monitor + static const Color blueColor = Color(0xFF4EB5FF); + static const Color blueGradientColorOne = Color(0xFFF1F7FD); + static const Color blueGradientColorTwo = Color(0xFFD9EFFF); } diff --git a/lib/widgets/buttons/custom_button.dart b/lib/widgets/buttons/custom_button.dart index 676f0bc..08d281f 100644 --- a/lib/widgets/buttons/custom_button.dart +++ b/lib/widgets/buttons/custom_button.dart @@ -75,7 +75,7 @@ class CustomButton extends StatelessWidget { children: [ if (icon != null) Padding( - padding: text.isNotEmpty ? EdgeInsets.only(right: 6.w, left: 6.w) : EdgeInsets.zero, + padding: text.isNotEmpty ? EdgeInsets.only(right: 8.w, left: 8.w) : EdgeInsets.zero, child: Utils.buildSvgWithAssets(icon: icon!, iconColor: iconColor, isDisabled: isDisabled, width: iconS, height: iconS), ), Visibility(