From 6abcdf135ecaad6f7fa1cb8864486f0f518880d2 Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Wed, 24 Jun 2026 14:40:45 +0300 Subject: [PATCH 01/10] Clarity implementation, LiveCare update insurance API implemented --- assets/langs/ar-SA.json | 4 +- assets/langs/en-US.json | 4 +- lib/core/api/api_client.dart | 135 +++++++- lib/core/api_consts.dart | 2 + lib/core/dependencies.dart | 5 + .../immediate_livecare_repo.dart | 43 +++ .../immediate_livecare_view_model.dart | 22 ++ lib/generated/locale_keys.g.dart | 2 + .../appointment_details_page.dart | 1 + .../immediate_livecare_payment_details.dart | 79 +++-- lib/services/clarity_service.dart | 291 ++++++++++++++++++ lib/splashPage.dart | 41 +++ lib/widgets/buttons/custom_button.dart | 122 +++++--- lib/widgets/clarity_interaction_tracker.dart | 200 ++++++++++++ 14 files changed, 872 insertions(+), 79 deletions(-) create mode 100644 lib/services/clarity_service.dart create mode 100644 lib/widgets/clarity_interaction_tracker.dart diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index d39e8bd7..0f434598 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -1958,5 +1958,7 @@ "invoiceHistory": "سجل الفواتير", "refund": "استرداد", "parkingQR": "رمز الاستجابة السريعة لوقوف السيارات", - "parkingQRDesc": "امسح رمز الاستجابة السريعة (QR Code) أدناه عند بوابة الخروج من موقف السيارات:" + "parkingQRDesc": "امسح رمز الاستجابة السريعة (QR Code) أدناه عند بوابة الخروج من موقف السيارات:", + "livecareUpdateInsuranceSent": "تم استلام طلب تحديث بيانات التأمين الخاص بك، ويجري العمل عليه حالياً. سنقوم بإشعارك فور اكتمال التحديث.", + "livecareUpdateInsurance": "سيتم تحويل طلب تحديث بيانات التأمين إلى فريق LiveCare المختص لاستكمال الإجراءات. هل تود المتابعة؟" } diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 61a6da03..cedcd40a 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -1947,7 +1947,9 @@ "amountOnly": "Amount", "invoiceHistory": "Invoice History", "parkingQR": "Parking QR Code", - "parkingQRDesc": "Scan the below QR Code at the exit parking gate:" + "parkingQRDesc": "Scan the below QR Code at the exit parking gate:", + "livecareUpdateInsuranceSent": "Your insurance update request is being processed. You will be notified once the update is completed.", + "livecareUpdateInsurance": "Please note that your insurance update request will be sent to the our LiveCare team for update. Would you like to proceed?" } diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index d2ea81f9..96b4d9f1 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -208,9 +208,9 @@ class ApiClientImp implements ApiClient { if (url == 'https://uat.hmgwebservices.com/Services/NHIC.svc/REST/GetPatientInfo') { url = "https://hmgwebservices.com/Services/NHIC.svc/REST/GetPatientInfo"; body['TokenID'] = "@dm!n"; - } + } - // body['TokenID'] = "@dm!n"; + body['TokenID'] = "@dm!n"; // body['PatientID'] = 4772993; // body['PatientID'] = 89754444; // body['PatientTypeID'] = 1; @@ -266,29 +266,77 @@ class ApiClientImp implements ApiClient { onFailure(message, -1, failureType: ConnectivityFailure(message)); _analytics.errorTracking.log(endPoint, error: "SocketException: $e"); + // Enhanced Clarity logging for network errors + Clarity.setCustomTag('api_err_type', 'socket_exception'); Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); - // Clarity.setCustomTag('api_err_request', _sanitizeRequestBody(requestBody)); - Clarity.sendCustomEvent('api_failure'); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'POST'); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_socket_exception'); return; } on http.ClientException catch (e) { final message = e.message.contains('Connection reset by peer') ? LocaleKeys.networkConnectionReset.tr() : LocaleKeys.networkErrorMessage.tr(); onFailure(message, -1, failureType: ConnectivityFailure(message)); _analytics.errorTracking.log(endPoint, error: "ClientException: $e"); + + // Enhanced Clarity logging + Clarity.setCustomTag('api_err_type', 'client_exception'); + Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'POST'); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_client_exception'); + return; } on TimeoutException catch (_) { final message = LocaleKeys.networkTimeout.tr(); onFailure(message, -1, failureType: ConnectivityFailure(message)); _analytics.errorTracking.log(endPoint, error: "TimeoutException"); + // Enhanced Clarity logging for timeouts + Clarity.setCustomTag('api_err_type', 'timeout'); Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); - // Clarity.setCustomTag('api_err_request', _sanitizeRequestBody(requestBody)); - Clarity.sendCustomEvent('api_timeout_failure'); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'POST'); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_timeout'); + return; } catch (e) { final message = LocaleKeys.networkUnknownError.tr(); onFailure(message, -1, failureType: ConnectivityFailure(message)); _analytics.errorTracking.log(endPoint, error: "UnknownException: $e"); + + // Enhanced Clarity logging for unknown errors + Clarity.setCustomTag('api_err_type', 'unknown_exception'); + Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'POST'); + + // Truncate exception message for Clarity + final exceptionMsg = e.toString(); + final truncatedMsg = exceptionMsg.length > 252 ? '...${exceptionMsg.substring(exceptionMsg.length - 252)}' : exceptionMsg; + Clarity.setCustomTag('api_err_exception', truncatedMsg); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_unknown_exception'); + return; } @@ -489,21 +537,74 @@ class ApiClientImp implements ApiClient { final message = e.message.contains('Connection reset by peer') ? LocaleKeys.networkConnectionReset.tr() : LocaleKeys.networkErrorMessage.tr(); onFailure(message, -1); _analytics.errorTracking.log(endPoint, error: "SocketException: $e"); + + // Clarity logging for GET requests + Clarity.setCustomTag('api_err_type', 'socket_exception'); + Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'GET'); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_get_socket_exception'); + return; } on http.ClientException catch (e) { final message = e.message.contains('Connection reset by peer') ? LocaleKeys.networkConnectionReset.tr() : LocaleKeys.networkErrorMessage.tr(); onFailure(message, -1); _analytics.errorTracking.log(endPoint, error: "ClientException: $e"); + + Clarity.setCustomTag('api_err_type', 'client_exception'); + Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'GET'); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_get_client_exception'); + return; } on TimeoutException catch (_) { final message = LocaleKeys.networkTimeout.tr(); onFailure(message, -1); _analytics.errorTracking.log(endPoint, error: "TimeoutException"); + + Clarity.setCustomTag('api_err_type', 'timeout'); + Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'GET'); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_get_timeout'); + return; } catch (e) { final message = LocaleKeys.networkUnknownError.tr(); onFailure(message, -1); _analytics.errorTracking.log(endPoint, error: "UnknownException: $e"); + + Clarity.setCustomTag('api_err_type', 'unknown_exception'); + Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'GET'); + + final exceptionMsg = e.toString(); + final truncatedMsg = exceptionMsg.length > 252 ? '...${exceptionMsg.substring(exceptionMsg.length - 252)}' : exceptionMsg; + Clarity.setCustomTag('api_err_exception', truncatedMsg); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_get_unknown_exception'); + return; } @@ -513,6 +614,19 @@ class ApiClientImp implements ApiClient { if (statusCode < 200 || statusCode >= 400) { onFailure(LocaleKeys.networkErrorWhileFetching.tr(), statusCode); logApiEndpointError(endPoint, LocaleKeys.networkErrorWhileFetching.tr(), statusCode); + + // Clarity logging for GET status code errors + Clarity.setCustomTag('api_err_type', 'status_code_error'); + Clarity.setCustomTag('api_err_url', _getEndofUrl(url.trim())); + Clarity.setCustomTag('api_err_endpoint', _getEndpointName(endPoint)); + Clarity.setCustomTag('api_err_method', 'GET'); + Clarity.setCustomTag('api_err_status', statusCode.toString()); + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_err_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('api_get_status_error'); } else { var parsed = json.decode(utf8.decode(response.bodyBytes)); onSuccess(parsed, statusCode); @@ -695,6 +809,15 @@ class ApiClientImp implements ApiClient { return '...${url.substring(url.length - (clarityLimit - 3))}'; } + String _getEndpointName(String endpoint) { + // Extract the last part of the endpoint path for cleaner tracking + const int clarityLimit = 100; + final cleanEndpoint = endpoint.split('/').last; + + if (cleanEndpoint.length <= clarityLimit) return cleanEndpoint; + return '...${cleanEndpoint.substring(cleanEndpoint.length - (clarityLimit - 3))}'; + } + String _sanitizeRequestBody(String body) { if (body.isEmpty) return ''; diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index aa51a5da..7e368079 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -622,6 +622,8 @@ var GET_ER_APPOINTMENT_TIME = 'Services/ER_VirtualCall.svc/REST/GetRestTime'; var ADD_NEW_CALL_FOR_PATIENT_ER = 'Services/DoctorApplication.svc/REST/NewCallForPatientER'; +var ADD_LIVECARE_INSURANCE_UPDATE_REQUEST = 'Services/ER_VirtualCall.svc/REST/ER_InsertRequests'; + var GET_LIVECARE_HISTORY = 'Services/ER_VirtualCall.svc/REST/GetPatientErVirtualHistory'; var CANCEL_LIVECARE_REQUEST = 'Services/ER_VirtualCall.svc/REST/DeleteErRequest'; var SEND_LIVECARE_INVOICE_EMAIL = 'Services/Notifications.svc/REST/SendInvoiceForLiveCare'; diff --git a/lib/core/dependencies.dart b/lib/core/dependencies.dart index 3c6e923e..c4f7402c 100644 --- a/lib/core/dependencies.dart +++ b/lib/core/dependencies.dart @@ -75,6 +75,7 @@ import 'package:hmg_patient_app_new/features/weather/weather_view_model.dart'; import 'package:hmg_patient_app_new/services/analytics/analytics_service.dart'; import 'package:hmg_patient_app_new/services/app_lifecycle_service.dart'; import 'package:hmg_patient_app_new/services/cache_service.dart'; +import 'package:hmg_patient_app_new/services/clarity_service.dart'; import 'package:hmg_patient_app_new/services/dialog_service.dart'; import 'package:hmg_patient_app_new/services/error_handler_service.dart'; import 'package:hmg_patient_app_new/services/firebase_service.dart'; @@ -366,5 +367,9 @@ class AppDependencies { )); getIt.registerLazySingleton(() => PayTabsViewModel(appState: getIt())); + + getIt.registerLazySingleton( + () => ClarityService(appState: getIt.get()), + ); } } diff --git a/lib/features/immediate_livecare/immediate_livecare_repo.dart b/lib/features/immediate_livecare/immediate_livecare_repo.dart index 8775378b..6726a09b 100644 --- a/lib/features/immediate_livecare/immediate_livecare_repo.dart +++ b/lib/features/immediate_livecare/immediate_livecare_repo.dart @@ -25,6 +25,9 @@ abstract class ImmediateLiveCareRepo { {Function(dynamic)? onSuccess, Function(String)? onError}); Future>>> getPatientLiveCareHistory({Function(dynamic)? onSuccess, Function(String)? onError}); + + Future>> addInsuranceUpdateRequestForLiveCare(String name, String mobileNumber, String fileNumber, + {Function(dynamic)? onSuccess, Function(String)? onError}); } class ImmediateLiveCareRepoImp implements ImmediateLiveCareRepo { @@ -250,4 +253,44 @@ class ImmediateLiveCareRepoImp implements ImmediateLiveCareRepo { return Left(UnknownFailure(e.toString())); } } + + @override + Future> addInsuranceUpdateRequestForLiveCare(String name, String mobileNumber, String fileNumber, + {Function(dynamic p1)? onSuccess, Function(String p1)? onError}) async { + Map mapDevice = { + "PatientName": name, + "MobileNumber": mobileNumber, + "FileNumber": fileNumber, + }; + + try { + GenericApiModel? apiResponse; + Failure? failure; + await apiClient.post( + ADD_LIVECARE_INSURANCE_UPDATE_REQUEST, + body: mapDevice, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + // onError!(error); + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + apiResponse = GenericApiModel( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: response, + ); + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, + ); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } } diff --git a/lib/features/immediate_livecare/immediate_livecare_view_model.dart b/lib/features/immediate_livecare/immediate_livecare_view_model.dart index 68246f00..84a79c02 100644 --- a/lib/features/immediate_livecare/immediate_livecare_view_model.dart +++ b/lib/features/immediate_livecare/immediate_livecare_view_model.dart @@ -245,6 +245,28 @@ class ImmediateLiveCareViewModel extends ChangeNotifier { ); } + Future addInsuranceUpdateRequestForLiveCare({Function(dynamic)? onSuccess, Function(String)? onError}) async { + final result = await immediateLiveCareRepo.addInsuranceUpdateRequestForLiveCare("${_appState.getAuthenticatedUser()?.firstName ?? ''} ${_appState.getAuthenticatedUser()?.lastName ?? ''}", + _appState.getAuthenticatedUser()!.mobileNumber ?? "", _appState.getAuthenticatedUser()!.patientId.toString() ?? ""); + + result.fold( + (failure) async { + onError!(failure.message); + }, + (apiResponse) { + if (apiResponse.messageStatus == 2) { + onError!(apiResponse.errorMessage ?? "Unknown error occurred"); + // dialogService.showErrorDialog(message: apiResponse.errorMessage!, onOkPressed: () {}); + } else if (apiResponse.messageStatus == 1) { + notifyListeners(); + if (onSuccess != null) { + onSuccess(apiResponse); + } + } + }, + ); + } + Future getPatientLiveCareHistory({Function(dynamic)? onSuccess, Function(String)? onError}) async { patientLiveCareHistoryList = []; patientHasPendingLiveCareRequest = false; diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 8d29d818..509fc86a 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -1940,5 +1940,7 @@ abstract class LocaleKeys { static const refund = 'refund'; static const parkingQR = 'parkingQR'; static const parkingQRDesc = 'parkingQRDesc'; + static const livecareUpdateInsuranceSent = 'livecareUpdateInsuranceSent'; + static const livecareUpdateInsurance = 'livecareUpdateInsurance'; } diff --git a/lib/presentation/appointments/appointment_details_page.dart b/lib/presentation/appointments/appointment_details_page.dart index daf0839a..d2faf2b1 100644 --- a/lib/presentation/appointments/appointment_details_page.dart +++ b/lib/presentation/appointments/appointment_details_page.dart @@ -40,6 +40,7 @@ import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/appoin import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/bell_switch.dart'; import 'package:hmg_patient_app_new/presentation/contact_us/feedback_page.dart'; import 'package:hmg_patient_app_new/presentation/prescriptions/prescription_detail_page.dart'; +import 'package:hmg_patient_app_new/services/clarity_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'; diff --git a/lib/presentation/book_appointment/livecare/immediate_livecare_payment_details.dart b/lib/presentation/book_appointment/livecare/immediate_livecare_payment_details.dart index 8bbf23af..57fd5e94 100644 --- a/lib/presentation/book_appointment/livecare/immediate_livecare_payment_details.dart +++ b/lib/presentation/book_appointment/livecare/immediate_livecare_payment_details.dart @@ -186,27 +186,64 @@ class ImmediateLiveCarePaymentDetails extends StatelessWidget { CustomButton( text: LocaleKeys.updateInsurance.tr(context: context), onPressed: () { - Navigator.of(context) - .push( - CustomPageRoute( - page: InsuranceHomePage(), - ), - ) - .then((val) async { - LoaderBottomSheet.showLoader(loadingText: LocaleKeys.fetchingFeesPleaseWait.tr()); - await immediateLiveCareVM.getLiveCareImmediateAppointmentFees(onSuccess: (val) { - LoaderBottomSheet.hideLoader(); - }, onError: (err) { - LoaderBottomSheet.hideLoader(); - showCommonBottomSheetWithoutHeight( - context, - child: Utils.getErrorWidget(loadingText: err), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - }); - }); + showCommonBottomSheetWithoutHeight( + title: LocaleKeys.notice.tr(context: context), + context, + child: Utils.getWarningWidget( + loadingText: LocaleKeys.livecareUpdateInsurance.tr(), + isShowActionButtons: true, + onCancelTap: () { + Navigator.pop(context); + }, + onConfirmTap: () async { + Navigator.pop(context); + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.loadingText.tr()); + await immediateLiveCareVM.addInsuranceUpdateRequestForLiveCare(onSuccess: (val) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getSuccessWidget(loadingText: LocaleKeys.livecareUpdateInsuranceSent.tr()), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }, onError: (err) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }); + }), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + + // Navigator.of(context) + // .push( + // CustomPageRoute( + // page: InsuranceHomePage(), + // ), + // ) + // .then((val) async { + // LoaderBottomSheet.showLoader(loadingText: LocaleKeys.fetchingFeesPleaseWait.tr()); + // await immediateLiveCareVM.getLiveCareImmediateAppointmentFees(onSuccess: (val) { + // LoaderBottomSheet.hideLoader(); + // }, onError: (err) { + // LoaderBottomSheet.hideLoader(); + // showCommonBottomSheetWithoutHeight( + // context, + // child: Utils.getErrorWidget(loadingText: err), + // callBackFunc: () {}, + // isFullScreen: false, + // isCloseButtonVisible: true, + // ); + // }); + // }); }, backgroundColor: AppColors.primaryRedColor, borderColor: AppColors.secondaryLightRedBorderColor, diff --git a/lib/services/clarity_service.dart b/lib/services/clarity_service.dart new file mode 100644 index 00000000..e8600821 --- /dev/null +++ b/lib/services/clarity_service.dart @@ -0,0 +1,291 @@ +import 'package:clarity_flutter/clarity_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hmg_patient_app_new/core/app_state.dart'; + +/// Centralized service for Clarity tracking and analytics +/// Provides methods for tracking user actions, errors, and app state +class ClarityService { + final AppState _appState; + + ClarityService({required AppState appState}) : _appState = appState; + + /// Initialize Clarity with user context + void setUserContext() { + if (kDebugMode) return; // Skip in debug mode + + try { + if (_appState.isAuthenticated) { + final user = _appState.getAuthenticatedUser(); + if (user != null) { + // Set user ID + Clarity.setCustomUserId(user.patientId.toString()); + + // Set user segments for filtering + Clarity.setCustomTag('user_type', user.patientType?.toString() ?? 'unknown'); + Clarity.setCustomTag('user_segment', user.outSa == 0 ? 'in_sa' : 'out_sa'); + Clarity.setCustomTag('auth_status', 'authenticated'); + } + } else { + Clarity.setCustomTag('auth_status', 'guest'); + } + } catch (e) { + debugPrint('Failed to set Clarity user context: $e'); + } + } + + /// Track screen views / route changes + void trackScreenView(String screenName, {Map? properties}) { + if (kDebugMode) return; + + try { + Clarity.setCustomTag('current_screen', _truncate(screenName, 100)); + + if (properties != null) { + properties.forEach((key, value) { + Clarity.setCustomTag(key, _truncate(value, 250)); + }); + } + + Clarity.sendCustomEvent('screen_view_$screenName'); + } catch (e) { + debugPrint('Failed to track screen view: $e'); + } + } + + /// Track user actions (button clicks, form submissions, etc.) + void trackAction(String actionName, {Map? properties}) { + if (kDebugMode) return; + + try { + Clarity.setCustomTag('action_name', _truncate(actionName, 100)); + + if (properties != null) { + properties.forEach((key, value) { + Clarity.setCustomTag('action_$key', _truncate(value, 250)); + }); + } + + Clarity.sendCustomEvent('user_action_$actionName'); + } catch (e) { + debugPrint('Failed to track action: $e'); + } + } + + /// Track API calls with success/failure + void trackApiCall({ + required String endpoint, + required String method, + required bool isSuccess, + int? statusCode, + String? errorType, + String? errorMessage, + }) { + if (kDebugMode) return; + + try { + final eventType = isSuccess ? 'api_success' : 'api_failure'; + + Clarity.setCustomTag('api_endpoint', _getEndpointName(endpoint)); + Clarity.setCustomTag('api_method', method); + Clarity.setCustomTag('api_status', isSuccess ? 'success' : 'failure'); + + if (statusCode != null) { + Clarity.setCustomTag('api_status_code', statusCode.toString()); + } + + if (errorType != null) { + Clarity.setCustomTag('api_error_type', _truncate(errorType, 100)); + } + + if (errorMessage != null) { + Clarity.setCustomTag('api_error_msg', _truncate(errorMessage, 250)); + } + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('api_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent(eventType); + } catch (e) { + debugPrint('Failed to track API call: $e'); + } + } + + /// Track app errors and exceptions + void trackError({ + required String errorType, + required String errorMessage, + String? stackTrace, + bool isFatal = false, + Map? additionalContext, + }) { + if (kDebugMode) return; + + try { + Clarity.setCustomTag('error_type', _truncate(errorType, 100)); + Clarity.setCustomTag('error_fatal', isFatal.toString()); + Clarity.setCustomTag('error_message', _truncate(errorMessage, 250)); + + if (stackTrace != null) { + // Only send first 250 chars of stack trace + Clarity.setCustomTag('error_stack', _truncate(stackTrace, 250)); + } + + if (additionalContext != null) { + additionalContext.forEach((key, value) { + Clarity.setCustomTag('error_$key', _truncate(value, 250)); + }); + } + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('error_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent(isFatal ? 'app_fatal_error' : 'app_error'); + } catch (e) { + debugPrint('Failed to track error: $e'); + } + } + + /// Track user frustration events (rage clicks, dead clicks) + void trackFrustration({ + required String frustrationType, + required String elementLabel, + Map? context, + }) { + if (kDebugMode) return; + + try { + Clarity.setCustomTag('frustration_type', frustrationType); + Clarity.setCustomTag('frustration_element', _truncate(elementLabel, 100)); + + if (context != null) { + context.forEach((key, value) { + Clarity.setCustomTag('frustration_$key', _truncate(value, 250)); + }); + } + + Clarity.sendCustomEvent('user_frustration_$frustrationType'); + } catch (e) { + debugPrint('Failed to track frustration: $e'); + } + } + + /// Track feature usage + void trackFeatureUsage(String featureName, {Map? metadata}) { + if (kDebugMode) return; + + try { + Clarity.setCustomTag('feature_name', _truncate(featureName, 100)); + + if (metadata != null) { + metadata.forEach((key, value) { + Clarity.setCustomTag('feature_$key', _truncate(value, 250)); + }); + } + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('feature_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('feature_used_$featureName'); + } catch (e) { + debugPrint('Failed to track feature usage: $e'); + } + } + + /// Track payment events + void trackPayment({ + required String paymentStatus, + String? paymentMethod, + String? amount, + String? transactionId, + }) { + if (kDebugMode) return; + + try { + Clarity.setCustomTag('payment_status', paymentStatus); + + if (paymentMethod != null) { + Clarity.setCustomTag('payment_method', paymentMethod); + } + + if (amount != null) { + Clarity.setCustomTag('payment_amount', amount); + } + + if (transactionId != null) { + Clarity.setCustomTag('payment_tx_id', _truncate(transactionId, 100)); + } + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('payment_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('payment_$paymentStatus'); + } catch (e) { + debugPrint('Failed to track payment: $e'); + } + } + + /// Track appointment booking flow + void trackAppointmentEvent({ + required String eventType, + String? doctorId, + String? clinicId, + String? appointmentDate, + }) { + if (kDebugMode) return; + + try { + Clarity.setCustomTag('appointment_event', eventType); + + if (doctorId != null) { + Clarity.setCustomTag('appointment_doctor', doctorId); + } + + if (clinicId != null) { + Clarity.setCustomTag('appointment_clinic', clinicId); + } + + if (appointmentDate != null) { + Clarity.setCustomTag('appointment_date', appointmentDate); + } + + if (_appState.isAuthenticated) { + Clarity.setCustomTag('appointment_user_id', _appState.getAuthenticatedUser()!.patientId.toString()); + } + + Clarity.sendCustomEvent('appointment_$eventType'); + } catch (e) { + debugPrint('Failed to track appointment event: $e'); + } + } + + /// Clear user context on logout + void clearUserContext() { + if (kDebugMode) return; + + try { + Clarity.setCustomTag('auth_status', 'guest'); + Clarity.setCustomTag('user_action', 'logout'); + Clarity.sendCustomEvent('user_logged_out'); + } catch (e) { + debugPrint('Failed to clear Clarity user context: $e'); + } + } + + /// Helper: Truncate string to fit Clarity's limits + String _truncate(String text, int maxLength) { + if (text.length <= maxLength) return text; + return '...${text.substring(text.length - (maxLength - 3))}'; + } + + /// Helper: Extract endpoint name from full URL + String _getEndpointName(String endpoint) { + final cleanEndpoint = endpoint.split('/').last; + return _truncate(cleanEndpoint, 100); + } +} + + diff --git a/lib/splashPage.dart b/lib/splashPage.dart index 2edf222e..dc49428a 100644 --- a/lib/splashPage.dart +++ b/lib/splashPage.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:clarity_flutter/clarity_flutter.dart'; import 'package:flutter/foundation.dart'; @@ -10,6 +11,7 @@ import 'package:hmg_patient_app_new/core/api/api_client.dart'; import 'package:hmg_patient_app_new/core/api_consts.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; import 'package:hmg_patient_app_new/core/app_export.dart'; +import 'package:hmg_patient_app_new/core/app_state.dart'; import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; @@ -91,6 +93,45 @@ class _SplashScreenState extends State { ); Clarity.initialize(context, config); + + // Set initial app context tags + try { + final appState = getIt.get(); + + // Set device and app metadata + Clarity.setCustomTag('app_version', ApiConsts.appVersionID.toString()); + Clarity.setCustomTag('device_type', appState.deviceTypeID ?? 'unknown'); + Clarity.setCustomTag('platform', Platform.isIOS ? 'ios' : 'android'); + + // Set user context if authenticated + if (appState.isAuthenticated) { + final user = appState.getAuthenticatedUser(); + if (user != null) { + // Set user ID for session tracking + Clarity.setCustomUserId(user.patientId.toString()); + + // Set custom tags for filtering sessions + Clarity.setCustomTag('user_type', user.patientType?.toString() ?? 'unknown'); + Clarity.setCustomTag('user_segment', user.outSa == 0 ? 'in_sa' : 'out_sa'); + Clarity.setCustomTag('auth_status', 'authenticated'); + + // Optional: Set other non-sensitive user properties + if (user.mobileNumber != null) { + // Mask phone number for privacy (show only last 4 digits) + final maskedPhone = '***${user.mobileNumber!.substring(user.mobileNumber!.length - 4)}'; + Clarity.setCustomTag('user_phone_masked', maskedPhone); + } + } + } else { + Clarity.setCustomTag('auth_status', 'guest'); + } + + // Send initialization event + Clarity.sendCustomEvent('app_initialized'); + + } catch (e) { + debugPrint('Failed to set Clarity user context: $e'); + } } navigateToTeleConsult() async { diff --git a/lib/widgets/buttons/custom_button.dart b/lib/widgets/buttons/custom_button.dart index 3f4902e0..f3eb94fb 100644 --- a/lib/widgets/buttons/custom_button.dart +++ b/lib/widgets/buttons/custom_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:clarity_flutter/clarity_flutter.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'; @@ -60,39 +61,73 @@ class CustomButton extends StatelessWidget { final fontS = fontSize ?? (16.f); final isButtonDisabled = isDisabled || onPressed == null; - return GestureDetector( - onTap: isButtonDisabled ? null : onPressed, - child: Container( - height: height == null - ? 56.h - : (isFoldable || isTablet) - ? height! + 10.h - : height, - width: width, - padding: padding, - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: isButtonDisabled ? AppColors.inputLabelTextColor.withValues(alpha: 0.3) : backgroundColor, - borderRadius: radius, - customBorder: BorderRadius.circular(radius), - side: borderSide ?? BorderSide(width: borderWidth.h, color: isButtonDisabled ? Colors.transparent : borderColor)), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (icon != null) - Padding( - padding: text.isNotEmpty ? EdgeInsets.only(right: 6.w, left: 6.w) : EdgeInsets.zero, - child: Utils.buildSvgWithAssets( - icon: icon!, - iconColor: isButtonDisabled ? AppColors.greyTextColor : iconColor, - isDisabled: isButtonDisabled, - width: iconS, - height: iconS, - applyThemeColor: applyThemeColor), - ), - if (text.isNotEmpty && icon != null) - Flexible( - child: Text( + return Semantics( + button: true, + enabled: !isButtonDisabled, + label: text, + hint: isButtonDisabled ? 'Button is disabled' : 'Tap to $text', + child: GestureDetector( + onTap: isButtonDisabled + ? null + : () { + // Log button tap to Clarity before executing callback + try { + Clarity.setCustomTag('button_text', text.length > 100 ? text.substring(0, 100) : text); + Clarity.setCustomTag('button_action', 'tap'); + Clarity.setCustomTag('button_disabled', 'false'); + Clarity.sendCustomEvent('button_tapped'); + } catch (e) { + // Silence Clarity errors to not interrupt user flow + } + + // Execute the actual button callback + onPressed?.call(); + }, + child: Container( + height: height == null + ? 56.h + : (isFoldable || isTablet) + ? height! + 10.h + : height, + width: width, + padding: padding, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: isButtonDisabled ? AppColors.inputLabelTextColor.withValues(alpha: 0.3) : backgroundColor, + borderRadius: radius, + customBorder: BorderRadius.circular(radius), + side: borderSide ?? BorderSide(width: borderWidth.h, color: isButtonDisabled ? Colors.transparent : borderColor)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) + Padding( + padding: text.isNotEmpty ? EdgeInsets.only(right: 6.w, left: 6.w) : EdgeInsets.zero, + child: Utils.buildSvgWithAssets( + icon: icon!, + iconColor: isButtonDisabled ? AppColors.greyTextColor : iconColor, + isDisabled: isButtonDisabled, + width: iconS, + height: iconS, + applyThemeColor: applyThemeColor), + ), + if (text.isNotEmpty && icon != null) + Flexible( + child: Text( + text, + overflow: textOverflow ?? TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + style: context.dynamicTextStyle( + fontSize: fontS, + color: isButtonDisabled ? AppColors.greyTextColor : textColor, + letterSpacing: 0, + fontWeight: isBold ? FontWeight.w700 : fontWeight, + ), + ), + ), + if (text.isNotEmpty && icon == null) + Text( text, overflow: textOverflow ?? TextOverflow.ellipsis, maxLines: 1, @@ -104,22 +139,9 @@ class CustomButton extends StatelessWidget { fontWeight: isBold ? FontWeight.w700 : fontWeight, ), ), - ), - if (text.isNotEmpty && icon == null) - Text( - text, - overflow: textOverflow ?? TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, - style: context.dynamicTextStyle( - fontSize: fontS, - color: isButtonDisabled ? AppColors.greyTextColor : textColor, - letterSpacing: 0, - fontWeight: isBold ? FontWeight.w700 : fontWeight, - ), - ), - ], - ), - )); + ], + ), + )), + ); } } diff --git a/lib/widgets/clarity_interaction_tracker.dart b/lib/widgets/clarity_interaction_tracker.dart new file mode 100644 index 00000000..e27aec86 --- /dev/null +++ b/lib/widgets/clarity_interaction_tracker.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:clarity_flutter/clarity_flutter.dart'; + +/// A wrapper widget that tracks user interactions with Clarity for UX frustration analysis. +/// Helps identify rage clicks, dead clicks, and rapid scrolls. +/// +/// Usage: +/// ```dart +/// ClarityInteractionTracker( +/// semanticLabel: 'Login Button', +/// interactionType: ClarityInteractionType.button, +/// onTap: () { /* your action */ }, +/// child: YourWidget(), +/// ) +/// ``` +class ClarityInteractionTracker extends StatelessWidget { + final Widget child; + final String semanticLabel; + final VoidCallback? onTap; + final ClarityInteractionType interactionType; + final Map? customTags; + final bool enabled; + + const ClarityInteractionTracker({ + super.key, + required this.child, + required this.semanticLabel, + this.onTap, + this.interactionType = ClarityInteractionType.button, + this.customTags, + this.enabled = true, + }); + + void _logInteraction(BuildContext context) { + if (!enabled) return; + + try { + // Set common tags + Clarity.setCustomTag('interaction_type', interactionType.name); + Clarity.setCustomTag('interaction_label', + semanticLabel.length > 100 ? semanticLabel.substring(0, 100) : semanticLabel + ); + Clarity.setCustomTag('interaction_enabled', enabled.toString()); + + // Set route/screen name if available + final route = ModalRoute.of(context); + if (route != null && route.settings.name != null) { + Clarity.setCustomTag('screen_route', route.settings.name!); + } + + // Add custom tags if provided + if (customTags != null) { + customTags!.forEach((key, value) { + // Ensure key and value fit Clarity's 255 char limit + final truncatedValue = value.length > 250 ? value.substring(0, 250) : value; + Clarity.setCustomTag(key, truncatedValue); + }); + } + + // Send event to Clarity + Clarity.sendCustomEvent('user_interaction_${interactionType.name}'); + } catch (e) { + // Silence Clarity errors to not interrupt user flow + debugPrint('Clarity interaction tracking error: $e'); + } + } + + @override + Widget build(BuildContext context) { + return Semantics( + label: semanticLabel, + button: interactionType == ClarityInteractionType.button, + enabled: enabled, + excludeSemantics: false, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled && onTap != null + ? () { + _logInteraction(context); + onTap?.call(); + } + : null, + child: child, + ), + ); + } +} + +/// Types of interactions that can be tracked +enum ClarityInteractionType { + button, + link, + card, + listItem, + icon, + image, + input, + custom, +} + +/// Extension to easily wrap any widget with Clarity tracking +extension ClarityTrackingExtension on Widget { + /// Wraps this widget with Clarity interaction tracking + Widget trackWithClarity({ + required String label, + VoidCallback? onTap, + ClarityInteractionType type = ClarityInteractionType.custom, + Map? customTags, + bool enabled = true, + }) { + return ClarityInteractionTracker( + semanticLabel: label, + onTap: onTap, + interactionType: type, + customTags: customTags, + enabled: enabled, + child: this, + ); + } +} + +/// Wrapper for ScrollView to track rapid scrolling behavior +class ClarityScrollTracker extends StatefulWidget { + final Widget child; + final String scrollViewLabel; + final ScrollController? controller; + + const ClarityScrollTracker({ + super.key, + required this.child, + required this.scrollViewLabel, + this.controller, + }); + + @override + State createState() => _ClarityScrollTrackerState(); +} + +class _ClarityScrollTrackerState extends State { + late ScrollController _controller; + bool _isRapidScrolling = false; + DateTime? _lastScrollTime; + int _rapidScrollCount = 0; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? ScrollController(); + _controller.addListener(_onScroll); + } + + void _onScroll() { + final now = DateTime.now(); + + // Detect rapid scrolling (scroll events within 100ms) + if (_lastScrollTime != null) { + final timeDiff = now.difference(_lastScrollTime!).inMilliseconds; + + if (timeDiff < 100) { + _rapidScrollCount++; + + if (!_isRapidScrolling && _rapidScrollCount > 5) { + _isRapidScrolling = true; + _logRapidScroll(); + } + } else if (timeDiff > 500) { + // Reset if scrolling slows down + _isRapidScrolling = false; + _rapidScrollCount = 0; + } + } + + _lastScrollTime = now; + } + + void _logRapidScroll() { + try { + Clarity.setCustomTag('scroll_type', 'rapid'); + Clarity.setCustomTag('scroll_view', widget.scrollViewLabel); + Clarity.setCustomTag('scroll_position', _controller.position.pixels.toString()); + Clarity.sendCustomEvent('rapid_scroll_detected'); + } catch (e) { + debugPrint('Clarity scroll tracking error: $e'); + } + } + + @override + void dispose() { + if (widget.controller == null) { + _controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + From 3dd4665f6e785939de18eb58c2eee0129c0492f6 Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Wed, 24 Jun 2026 18:07:47 +0300 Subject: [PATCH 02/10] Payfort request & response insert --- lib/core/api/api_client.dart | 2 +- lib/core/api_consts.dart | 5 +- .../models/payfort_request_insert_model.dart | 128 ++++++++++++++++++ .../models/payfort_response_insert_model.dart | 77 +++++++++++ lib/features/payfort/payfort_repo.dart | 59 ++++++++ lib/features/payfort/payfort_view_model.dart | 103 +++++++++++++- .../appointment_payment_page.dart | 14 ++ .../immediate_livecare_payment_page.dart | 14 ++ .../waiting_appointment_payment_page.dart | 14 ++ .../wallet_payment_confirm_page.dart | 19 ++- .../ancillary_order_payment_page.dart | 14 ++ 11 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 lib/features/payfort/models/payfort_request_insert_model.dart create mode 100644 lib/features/payfort/models/payfort_response_insert_model.dart diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 96b4d9f1..56eadd57 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -211,7 +211,7 @@ class ApiClientImp implements ApiClient { } body['TokenID'] = "@dm!n"; - // body['PatientID'] = 4772993; + // body['PatientID'] = 255375; // body['PatientID'] = 89754444; // body['PatientTypeID'] = 1; // body['PatientOutSA'] = 0; diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index 7e368079..04d0c962 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -988,7 +988,10 @@ var IS_DOCTOR_AVAILABLE_BY_CALENDAR_SCHEDULE = 'Services/OUTPs.svc/REST/HIS_IsDo //PAYFORT var getPayFortProjectDetails = "Services/PayFort_Serv.svc/REST/GetPayFortProjectDetails"; -var addPayFortApplePayResponse = "Services/PayFort_Serv.svc/REST/AddResponse"; + +var ADD_PAYFORT_REQUEST = "Services/PayFort_Serv.svc/REST/AddRequest"; +var ADD_PAYFORT_RESPONSE = "Services/PayFort_Serv.svc/REST/AddResponse"; + // var payFortEnvironment = FortEnvironment.production; // var applePayMerchantId = "merchant.com.hmgwebservices"; // var payFortEnvironment = FortEnvironment.test; diff --git a/lib/features/payfort/models/payfort_request_insert_model.dart b/lib/features/payfort/models/payfort_request_insert_model.dart new file mode 100644 index 00000000..56f354f5 --- /dev/null +++ b/lib/features/payfort/models/payfort_request_insert_model.dart @@ -0,0 +1,128 @@ +class PayfortRequestInsertModel { + String? accessCode; + String? commandType; + num? amount; + String? customerEmail; + String? orderDescription; + String? serviceID; + String? returnURL; + String? paymentOption; + String? clientRequestID; + String? projectID; + String? fileNumber; + String? customerName; + String? currency; + String? liveServiceID; + String? longitude; + String? latitude; + String? merchantReference; + String? merchantIdentifier; + String? deviceToken; + String? sHARequestPhase; + String? sHAResponsePhase; + String? signature; + String? language; + String? integrationId; + String? iPAdress; + String? isSchedule; + String? appointmentDate; + String? appointmentNo; + String? doctorID; + + PayfortRequestInsertModel( + {this.accessCode, + this.commandType, + this.amount, + this.customerEmail, + this.orderDescription, + this.serviceID, + this.returnURL, + this.paymentOption, + this.clientRequestID, + this.projectID, + this.fileNumber, + this.customerName, + this.currency, + this.liveServiceID, + this.longitude, + this.latitude, + this.merchantReference, + this.merchantIdentifier, + this.deviceToken, + this.sHARequestPhase, + this.sHAResponsePhase, + this.signature, + this.language, + this.integrationId, + this.iPAdress, + this.isSchedule, + this.appointmentDate, + this.appointmentNo, + this.doctorID}); + + PayfortRequestInsertModel.fromJson(Map json) { + accessCode = json['Access_code']; + commandType = json['CommandType']; + amount = json['Amount']; + customerEmail = json['CustomerEmail']; + orderDescription = json['OrderDescription']; + serviceID = json['Service_ID']; + returnURL = json['ReturnURL']; + paymentOption = json['PaymentOption']; + clientRequestID = json['ClientRequestID']; + projectID = json['ProjectID']; + fileNumber = json['FileNumber']; + customerName = json['CustomerName']; + currency = json['Currency']; + liveServiceID = json['Live_ServiceID']; + longitude = json['Longitude']; + latitude = json['Latitude']; + merchantReference = json['Merchant_Reference']; + merchantIdentifier = json['Merchant_Identifier']; + deviceToken = json['DeviceToken']; + sHARequestPhase = json['SHA_RequestPhase']; + sHAResponsePhase = json['SHA_ResponsePhase']; + signature = json['Signature']; + language = json['Language']; + integrationId = json['Integration_Id']; + iPAdress = json['IPAdress']; + isSchedule = json['IsSchedule']; + appointmentDate = json['AppointmentDate']; + appointmentNo = json['AppointmentNo']; + doctorID = json['DoctorID']; + } + + Map toJson() { + final Map data = new Map(); + data['Access_code'] = this.accessCode; + data['CommandType'] = this.commandType; + data['Amount'] = this.amount; + data['CustomerEmail'] = this.customerEmail; + data['OrderDescription'] = this.orderDescription; + data['Service_ID'] = this.serviceID; + data['ReturnURL'] = this.returnURL; + data['PaymentOption'] = this.paymentOption; + data['ClientRequestID'] = this.clientRequestID; + data['ProjectID'] = this.projectID; + data['FileNumber'] = this.fileNumber; + data['CustomerName'] = this.customerName; + data['Currency'] = this.currency; + data['Live_ServiceID'] = this.liveServiceID; + data['Longitude'] = this.longitude; + data['Latitude'] = this.latitude; + data['Merchant_Reference'] = this.merchantReference; + data['Merchant_Identifier'] = this.merchantIdentifier; + data['DeviceToken'] = this.deviceToken; + data['SHA_RequestPhase'] = this.sHARequestPhase; + data['SHA_ResponsePhase'] = this.sHAResponsePhase; + data['Signature'] = this.signature; + data['Language'] = this.language; + data['Integration_Id'] = this.integrationId; + data['IPAdress'] = this.iPAdress; + data['IsSchedule'] = this.isSchedule; + data['AppointmentDate'] = this.appointmentDate; + data['AppointmentNo'] = this.appointmentNo; + data['DoctorID'] = this.doctorID; + return data; + } +} diff --git a/lib/features/payfort/models/payfort_response_insert_model.dart b/lib/features/payfort/models/payfort_response_insert_model.dart new file mode 100644 index 00000000..452ca2a5 --- /dev/null +++ b/lib/features/payfort/models/payfort_response_insert_model.dart @@ -0,0 +1,77 @@ +class PayfortResponseInsertModel { + String? fortId; + String? commandType; + num? amount; + String? sadadOLP; + String? paymentOption; + String? customerIP; + String? eCI; + String? responseMessage; + String? cardNumber; + String? cardBin; + int? installmentsNumber; + String? status; + String? merchantRef; + bool? isRefund; + bool? remmeberMe; + String? reconciliationReference; + + PayfortResponseInsertModel({ + this.fortId, + this.commandType, + this.amount, + this.sadadOLP, + this.paymentOption, + this.customerIP, + this.eCI, + this.responseMessage, + this.cardNumber, + this.cardBin, + this.installmentsNumber, + this.status, + this.merchantRef, + this.isRefund, + this.remmeberMe, + this.reconciliationReference, + }); + + PayfortResponseInsertModel.fromJson(Map json) { + fortId = json['Fort_id']; + commandType = json['CommandType']; + amount = json['Amount']; + sadadOLP = json['Sadad_OLP']; + paymentOption = json['Payment_Option']; + customerIP = json['Customer_IP']; + eCI = json['ECI']; + responseMessage = json['Response_Message']; + cardNumber = json['Card_Number']; + cardBin = json['Card_Bin']; + installmentsNumber = json['Installments_Number']; + status = json['Status']; + merchantRef = json['Merchant_Ref']; + isRefund = json['IsRefund']; + remmeberMe = json['RemmeberMe']; + reconciliationReference = json['Reconciliation_Reference']; + } + + Map toJson() { + final Map data = Map(); + data['Fort_id'] = this.fortId; + data['CommandType'] = this.commandType; + data['Amount'] = this.amount; + data['Sadad_OLP'] = this.sadadOLP; + data['Payment_Option'] = this.paymentOption; + data['Customer_IP'] = this.customerIP; + data['ECI'] = this.eCI; + data['Response_Message'] = this.responseMessage; + data['Card_Number'] = this.cardNumber; + data['Card_Bin'] = this.cardBin; + data['Installments_Number'] = this.installmentsNumber; + data['Status'] = this.status; + data['Merchant_Ref'] = this.merchantRef; + data['IsRefund'] = this.isRefund; + data['RemmeberMe'] = this.remmeberMe; + data['Reconciliation_Reference'] = this.reconciliationReference; + return data; + } +} diff --git a/lib/features/payfort/payfort_repo.dart b/lib/features/payfort/payfort_repo.dart index c00b01c2..3cd27a9d 100644 --- a/lib/features/payfort/payfort_repo.dart +++ b/lib/features/payfort/payfort_repo.dart @@ -8,6 +8,8 @@ import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/ import 'package:hmg_patient_app_new/features/payfort/models/apple_pay_request_insert_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/payfort_check_payment_status_response_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/payfort_project_details_resp_model.dart'; +import 'package:hmg_patient_app_new/features/payfort/models/payfort_request_insert_model.dart'; +import 'package:hmg_patient_app_new/features/payfort/models/payfort_response_insert_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/sdk_token_response_model.dart'; import 'package:hmg_patient_app_new/services/logger_service.dart'; @@ -28,6 +30,10 @@ abstract class PayfortRepo { {required String responseMessage, required String status, required String clientRequestID, required String tamaraOrderID}); Future>> getTamaraInstallmentsDetails(); + + Future>> payfortRequestInsert({required PayfortRequestInsertModel payfortRequestInsertModel}); + + Future>> payfortResponseInsert({required PayfortResponseInsertModel payfortResponseInsertModel}); } class PayfortRepoImp implements PayfortRepo { @@ -291,4 +297,57 @@ class PayfortRepoImp implements PayfortRepo { } } + @override + Future> payfortRequestInsert({required PayfortRequestInsertModel payfortRequestInsertModel}) async { + try { + GenericApiModel? apiResponse; + Failure? failure; + await apiClient.post(ADD_PAYFORT_REQUEST, body: payfortRequestInsertModel.toJson(), onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + }, onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + apiResponse = GenericApiModel( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: response, + ); + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, isAllowAny: true, isPaymentServices: true); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> payfortResponseInsert({required PayfortResponseInsertModel payfortResponseInsertModel}) async { + try { + GenericApiModel? apiResponse; + Failure? failure; + await apiClient.post(ADD_PAYFORT_RESPONSE, body: payfortResponseInsertModel.toJson(), onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + }, onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + apiResponse = GenericApiModel( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: response, + ); + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, isAllowAny: true, isPaymentServices: true); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } } diff --git a/lib/features/payfort/payfort_view_model.dart b/lib/features/payfort/payfort_view_model.dart index aebb8d5f..fd0cfe10 100644 --- a/lib/features/payfort/payfort_view_model.dart +++ b/lib/features/payfort/payfort_view_model.dart @@ -8,11 +8,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; import 'package:hmg_patient_app_new/core/api_consts.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; +import 'package:hmg_patient_app_new/core/cache_consts.dart'; import 'package:hmg_patient_app_new/core/dependencies.dart'; +import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/get_tamara_installments_details_response_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/apple_pay_request_insert_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/payfort_check_payment_status_response_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/payfort_project_details_resp_model.dart'; +import 'package:hmg_patient_app_new/features/payfort/models/payfort_request_insert_model.dart'; +import 'package:hmg_patient_app_new/features/payfort/models/payfort_response_insert_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/sdk_token_response_model.dart'; import 'package:hmg_patient_app_new/features/payfort/payfort_repo.dart'; import 'package:hmg_patient_app_new/services/error_handler_service.dart'; @@ -92,6 +96,44 @@ class PayfortViewModel extends ChangeNotifier { ); } + Future payfortRequestInsert({required PayfortRequestInsertModel payfortRequestInsertModel, Function(dynamic)? onSuccess, Function(String)? onError}) async { + final result = await payfortRepo.payfortRequestInsert(payfortRequestInsertModel: payfortRequestInsertModel); + + result.fold( + (failure) async => await errorHandlerService.handleError(failure: failure), + (apiResponse) { + if (apiResponse.messageStatus == 2) { + // dialogService.showErrorDialog(message: apiResponse.errorMessage!, onOkPressed: () {}); + } else if (apiResponse.messageStatus == 1) { + // payfortProjectDetailsRespModel = apiResponse.data!; + notifyListeners(); + if (onSuccess != null) { + onSuccess(apiResponse); + } + } + }, + ); + } + + Future payfortResponseInsert({required PayfortResponseInsertModel payfortResponseInsertModel, Function(dynamic)? onSuccess, Function(String)? onError}) async { + final result = await payfortRepo.payfortResponseInsert(payfortResponseInsertModel: payfortResponseInsertModel); + + result.fold( + (failure) async => await errorHandlerService.handleError(failure: failure), + (apiResponse) { + if (apiResponse.messageStatus == 2) { + // dialogService.showErrorDialog(message: apiResponse.errorMessage!, onOkPressed: () {}); + } else if (apiResponse.messageStatus == 1) { + // payfortProjectDetailsRespModel = apiResponse.data!; + notifyListeners(); + if (onSuccess != null) { + onSuccess(apiResponse); + } + } + }, + ); + } + Future checkPaymentStatus({required String transactionID, Function(dynamic)? onSuccess, Function(String)? onError}) async { final result = await payfortRepo.checkPaymentStatus(transactionID: transactionID); @@ -291,13 +333,19 @@ class PayfortViewModel extends ChangeNotifier { } Future initiatePaymentWithCard( // var request, { - { + String paymentOption, + String liveServiceID, + String isScheduleLiveCare, + String appointmentDate, + String appointmentNo, + String doctorID, { SucceededCallback? onSucceeded, FailedCallback? onFailed, String countryIsoCode = "SA", String transactionID = "", String amount = "0", String fileNumber = "0", + String orderDescription = "", }) async { dynamic paymentResult; @@ -325,6 +373,38 @@ class PayfortViewModel extends ChangeNotifier { "customer_name": "${getIt.get().getAuthenticatedUser()!.firstName} ${getIt.get().getAuthenticatedUser()!.lastName}", }; + PayfortRequestInsertModel payfortRequestInsertModel = PayfortRequestInsertModel(); + + payfortRequestInsertModel.accessCode = payfortProjectDetailsRespModel!.accessCode; + payfortRequestInsertModel.commandType = "PURCHASE"; + payfortRequestInsertModel.amount = num.parse(amount); + payfortRequestInsertModel.customerEmail = getIt.get().getAuthenticatedUser()!.emailAddress; + payfortRequestInsertModel.orderDescription = orderDescription; + payfortRequestInsertModel.serviceID = payfortProjectDetailsRespModel!.servID.toString(); + payfortRequestInsertModel.paymentOption = paymentOption; + payfortRequestInsertModel.clientRequestID = transactionID; + payfortRequestInsertModel.projectID = payfortProjectDetailsRespModel!.projectID.toString(); + payfortRequestInsertModel.fileNumber = getIt.get().getAuthenticatedUser()!.patientId.toString(); + payfortRequestInsertModel.customerName = "${getIt.get().getAuthenticatedUser()!.firstName} ${getIt.get().getAuthenticatedUser()!.lastName}"; + payfortRequestInsertModel.currency = getIt.get().getAuthenticatedUser()!.outSa! == 0 ? "SAR" : "AED"; + payfortRequestInsertModel.liveServiceID = liveServiceID; + payfortRequestInsertModel.latitude = getIt.get().userLat.toString(); + payfortRequestInsertModel.longitude = getIt.get().userLong.toString(); + payfortRequestInsertModel.merchantReference = transactionID; + payfortRequestInsertModel.merchantIdentifier = payfortProjectDetailsRespModel!.merchantIdentifier; + payfortRequestInsertModel.deviceToken = await Utils.getStringFromPrefs(CacheConst.pushToken); + payfortRequestInsertModel.sHARequestPhase = payfortProjectDetailsRespModel!.shaRequest; + payfortRequestInsertModel.sHAResponsePhase = payfortProjectDetailsRespModel!.shaResponse; + payfortRequestInsertModel.signature = sdkTokenResponse!.signature; + payfortRequestInsertModel.language = getIt.get().isArabic() ? "ar" : "en"; + payfortRequestInsertModel.integrationId = "1"; + payfortRequestInsertModel.isSchedule = isScheduleLiveCare; + payfortRequestInsertModel.appointmentDate = appointmentDate; + payfortRequestInsertModel.appointmentNo = appointmentNo; + payfortRequestInsertModel.doctorID = doctorID; + + await payfortRequestInsert(payfortRequestInsertModel: payfortRequestInsertModel); + LoaderBottomSheet.hideLoader(); paymentResult = await FlutterAmazonpaymentservices.normalPay(request, ApiConsts.payFortEnvironmentType, isShowResponsePage: false); @@ -332,6 +412,27 @@ class PayfortViewModel extends ChangeNotifier { String responseCode = paymentMap['response_code'] ?? ""; print("Payment Success Data: ${paymentMap.toString()}"); + PayfortResponseInsertModel payfortResponseInsertModel = PayfortResponseInsertModel(); + + payfortResponseInsertModel.fortId = paymentMap['fort_id'] ?? ""; + payfortResponseInsertModel.commandType = paymentMap['command'] ?? ""; + payfortResponseInsertModel.amount = num.tryParse(paymentMap['amount'] ?? "0") ?? 0; + payfortResponseInsertModel.sadadOLP = ""; + payfortResponseInsertModel.paymentOption = paymentMap['payment_option'] ?? ""; + payfortResponseInsertModel.customerIP = paymentMap['customer_ip'] ?? ""; + payfortResponseInsertModel.eCI = paymentMap['eci'] ?? ""; + payfortResponseInsertModel.responseMessage = paymentMap['response_message'] ?? ""; + payfortResponseInsertModel.cardNumber = paymentMap['card_number'] ?? ""; + payfortResponseInsertModel.cardBin = paymentMap['card_bin'] ?? ""; + payfortResponseInsertModel.installmentsNumber = int.tryParse(paymentMap['installments_number'] ?? "0") ?? 0; + payfortResponseInsertModel.status = paymentMap['status'] ?? ""; + payfortResponseInsertModel.merchantRef = paymentMap['merchant_reference'] ?? ""; + payfortResponseInsertModel.isRefund = false; + payfortResponseInsertModel.remmeberMe = true; + payfortResponseInsertModel.reconciliationReference = ""; + + await payfortResponseInsert(payfortResponseInsertModel: payfortResponseInsertModel); + if (responseCode.startsWith("14") || responseCode == "20064") { if (onSucceeded != null) { onSucceeded(PayFortResult.fromMap(paymentMap)); diff --git a/lib/presentation/appointments/appointment_payment_page.dart b/lib/presentation/appointments/appointment_payment_page.dart index b3814f00..66c88a28 100644 --- a/lib/presentation/appointments/appointment_payment_page.dart +++ b/lib/presentation/appointments/appointment_payment_page.dart @@ -228,8 +228,15 @@ class _AppointmentPaymentPageState extends State { serviceId: ServiceTypeEnum.appointmentPayment.getIdFromServiceEnum(), projectId: widget.patientAppointmentHistoryResponseModel.projectID, integrationId: 1); payfortViewModel.initiatePaymentWithCard( + "mada", + "0", + widget.patientAppointmentHistoryResponseModel.isLiveCareAppointment! ? "true" : "false", + widget.patientAppointmentHistoryResponseModel.appointmentDate!, + widget.patientAppointmentHistoryResponseModel.appointmentNo!, + widget.patientAppointmentHistoryResponseModel.doctorID!, transactionID: transID, fileNumber: appState.getAuthenticatedUser()!.patientId.toString(), + orderDescription: "Appointment Payment", amount: myAppointmentsViewModel.patientAppointmentShareResponseModel!.patientShareWithTax!.toString(), onSucceeded: (paymentResult) async { print("Payment Success Data: ${paymentResult.toString()}"); @@ -444,9 +451,16 @@ class _AppointmentPaymentPageState extends State { serviceId: ServiceTypeEnum.appointmentPayment.getIdFromServiceEnum(), projectId: widget.patientAppointmentHistoryResponseModel.projectID, integrationId: 1); payfortViewModel.initiatePaymentWithCard( + "visa", + "0", + widget.patientAppointmentHistoryResponseModel.isLiveCareAppointment! ? "true" : "false", + widget.patientAppointmentHistoryResponseModel.appointmentDate!, + widget.patientAppointmentHistoryResponseModel.appointmentNo!, + widget.patientAppointmentHistoryResponseModel.doctorID!, transactionID: transID, fileNumber: appState.getAuthenticatedUser()!.patientId.toString(), amount: myAppointmentsViewModel.patientAppointmentShareResponseModel!.patientShareWithTax!.toString(), + orderDescription: "Appointment Payment", onSucceeded: (paymentResult) async { print("Payment Success Data: ${paymentResult.toString()}"); LoaderBottomSheet.showLoader(loadingText: LocaleKeys.checkingPaymentStatusPleaseWait.tr(context: context)); diff --git a/lib/presentation/book_appointment/livecare/immediate_livecare_payment_page.dart b/lib/presentation/book_appointment/livecare/immediate_livecare_payment_page.dart index 04fd3073..972606a9 100644 --- a/lib/presentation/book_appointment/livecare/immediate_livecare_payment_page.dart +++ b/lib/presentation/book_appointment/livecare/immediate_livecare_payment_page.dart @@ -184,9 +184,16 @@ class _ImmediateLiveCarePaymentPageState extends State { serviceId: ServiceTypeEnum.advancePayment.getIdFromServiceEnum(), projectId: habibWalletVM.selectedHospital!.iD!, integrationId: 1); payfortViewModel.initiatePaymentWithCard( + "mada", + "0", + "false", + DateUtil.convertDateToString(DateTime.now()), + "0", + "0", transactionID: transID, fileNumber: habibWalletVM.fileNumber.toString(), amount: habibWalletVM.walletRechargeAmount.toString(), + orderDescription: "Advance Payment", onSucceeded: (paymentResult) async { print("Payment Success Data: ${paymentResult.toString()}"); LoaderBottomSheet.showLoader(); @@ -204,7 +212,7 @@ class _WalletPaymentConfirmPageState extends State { habibWalletVM.setIsWalletAmountToBeLoaded(true); habibWalletVM.initHabibWalletProvider(); habibWalletVM.getPatientBalanceAmount(); - Navigator.of(getIt.get().navigatorKey.currentContext!).pop(); + // Navigator.of(getIt.get().navigatorKey.currentContext!).pop(); Navigator.of(getIt.get().navigatorKey.currentContext!).pop(); }, isFullScreen: false, isCloseButtonVisible: true, isAutoDismiss: true); }, @@ -353,9 +361,16 @@ class _WalletPaymentConfirmPageState extends State { serviceId: ServiceTypeEnum.advancePayment.getIdFromServiceEnum(), projectId: habibWalletVM.selectedHospital!.iD!, integrationId: 1); payfortViewModel.initiatePaymentWithCard( + "visa", + "0", + "false", + DateUtil.convertDateToString(DateTime.now()), + "0", + "0", transactionID: transID, fileNumber: habibWalletVM.fileNumber.toString(), amount: habibWalletVM.walletRechargeAmount.toString(), + orderDescription: "Advance Payment", onSucceeded: (paymentResult) async { print("Payment Success Data: ${paymentResult.toString()}"); LoaderBottomSheet.showLoader(); @@ -380,7 +395,7 @@ class _WalletPaymentConfirmPageState extends State { habibWalletVM.initHabibWalletProvider(); habibWalletVM.getPatientBalanceAmount(); Navigator.of(getIt.get().navigatorKey.currentContext!).pop(); - Navigator.of(getIt.get().navigatorKey.currentContext!).pop(); + // Navigator.of(getIt.get().navigatorKey.currentContext!).pop(); }, isFullScreen: false, isCloseButtonVisible: true, isAutoDismiss: true); }, onError: (err) { diff --git a/lib/presentation/todo_section/ancillary_order_payment_page.dart b/lib/presentation/todo_section/ancillary_order_payment_page.dart index 99ed0928..ae0a4c02 100644 --- a/lib/presentation/todo_section/ancillary_order_payment_page.dart +++ b/lib/presentation/todo_section/ancillary_order_payment_page.dart @@ -171,9 +171,16 @@ class _AncillaryOrderPaymentPageState extends State { ); payfortViewModel.initiatePaymentWithCard( + "mada", + "0", + "false", + DateUtil.convertDateToString(DateTime.now()), + "0", + "0", transactionID: transID, fileNumber: appState.getAuthenticatedUser()!.patientId.toString(), amount: widget.totalAmount.toString(), + orderDescription: "Ancillary Order Payment", onSucceeded: (paymentResult) async { print("Payment Success Data: ${paymentResult.toString()}"); LoaderBottomSheet.showLoader(loadingText: LocaleKeys.checkingPaymentStatusPleaseWait.tr(context: context)); @@ -286,9 +293,16 @@ class _AncillaryOrderPaymentPageState extends State { ); payfortViewModel.initiatePaymentWithCard( + "visa", + "0", + "false", + DateUtil.convertDateToString(DateTime.now()), + "0", + "0", transactionID: transID, fileNumber: appState.getAuthenticatedUser()!.patientId.toString(), amount: widget.totalAmount.toString(), + orderDescription: "Ancillary Order Payment", onSucceeded: (paymentResult) async { print("Payment Success Data: ${paymentResult.toString()}"); LoaderBottomSheet.showLoader(loadingText: LocaleKeys.checkingPaymentStatusPleaseWait.tr(context: context)); From e64f147f4968027cf8c404952f09dea99d11e83f Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Thu, 25 Jun 2026 15:12:22 +0300 Subject: [PATCH 03/10] updates --- lib/core/api/api_client.dart | 4 +- .../my_appointments_view_model.dart | 7 +- .../appointment_details_page.dart | 10 +++ .../appointment_payment_page.dart | 8 +-- .../widgets/select_gender_widget.dart | 9 ++- lib/presentation/home/landing_page.dart | 68 ++++++++++--------- 6 files changed, 65 insertions(+), 41 deletions(-) diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 56eadd57..6a44cb0e 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -210,8 +210,8 @@ class ApiClientImp implements ApiClient { body['TokenID'] = "@dm!n"; } - body['TokenID'] = "@dm!n"; - // body['PatientID'] = 255375; + // body['TokenID'] = "@dm!n"; + // body['PatientID'] = 4769748; // body['PatientID'] = 89754444; // body['PatientTypeID'] = 1; // body['PatientOutSA'] = 0; diff --git a/lib/features/my_appointments/my_appointments_view_model.dart b/lib/features/my_appointments/my_appointments_view_model.dart index 0862c40d..9182f9e7 100644 --- a/lib/features/my_appointments/my_appointments_view_model.dart +++ b/lib/features/my_appointments/my_appointments_view_model.dart @@ -588,7 +588,12 @@ class MyAppointmentsViewModel extends ChangeNotifier { final result = await myAppointmentsRepo.confirmAppointment(patientAppointmentHistoryResponseModel: patientAppointmentHistoryResponseModel); result.fold( - (failure) async => await errorHandlerService.handleError(failure: failure), + // (failure) async => await errorHandlerService.handleError(failure: failure), + (failure) async { + if(onError != null) { + onError(failure.message); + } + }, (apiResponse) { if (apiResponse.messageStatus == 2) { onError!(apiResponse.errorMessage!); diff --git a/lib/presentation/appointments/appointment_details_page.dart b/lib/presentation/appointments/appointment_details_page.dart index d2faf2b1..4d181230 100644 --- a/lib/presentation/appointments/appointment_details_page.dart +++ b/lib/presentation/appointments/appointment_details_page.dart @@ -1216,6 +1216,16 @@ class _AppointmentDetailsPageState extends State { isDismissible: false, isFullScreen: false, isSuccessDialog: true); + }, + onError: (err) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); }); // LoaderBottomSheet.hideLoader(); case 15: diff --git a/lib/presentation/appointments/appointment_payment_page.dart b/lib/presentation/appointments/appointment_payment_page.dart index 66c88a28..46130b12 100644 --- a/lib/presentation/appointments/appointment_payment_page.dart +++ b/lib/presentation/appointments/appointment_payment_page.dart @@ -232,8 +232,8 @@ class _AppointmentPaymentPageState extends State { "0", widget.patientAppointmentHistoryResponseModel.isLiveCareAppointment! ? "true" : "false", widget.patientAppointmentHistoryResponseModel.appointmentDate!, - widget.patientAppointmentHistoryResponseModel.appointmentNo!, - widget.patientAppointmentHistoryResponseModel.doctorID!, + widget.patientAppointmentHistoryResponseModel.appointmentNo.toString(), + widget.patientAppointmentHistoryResponseModel.doctorID.toString(), transactionID: transID, fileNumber: appState.getAuthenticatedUser()!.patientId.toString(), orderDescription: "Appointment Payment", @@ -455,8 +455,8 @@ class _AppointmentPaymentPageState extends State { "0", widget.patientAppointmentHistoryResponseModel.isLiveCareAppointment! ? "true" : "false", widget.patientAppointmentHistoryResponseModel.appointmentDate!, - widget.patientAppointmentHistoryResponseModel.appointmentNo!, - widget.patientAppointmentHistoryResponseModel.doctorID!, + widget.patientAppointmentHistoryResponseModel.appointmentNo.toString(), + widget.patientAppointmentHistoryResponseModel.doctorID.toString(), transactionID: transID, fileNumber: appState.getAuthenticatedUser()!.patientId.toString(), amount: myAppointmentsViewModel.patientAppointmentShareResponseModel!.patientShareWithTax!.toString(), diff --git a/lib/presentation/blood_donation/widgets/select_gender_widget.dart b/lib/presentation/blood_donation/widgets/select_gender_widget.dart index 1368c4e3..e51d3e78 100644 --- a/lib/presentation/blood_donation/widgets/select_gender_widget.dart +++ b/lib/presentation/blood_donation/widgets/select_gender_widget.dart @@ -57,9 +57,12 @@ class SelectGenderWidget extends StatelessWidget { ), ], ).paddingSymmetrical(16.h, 16.h).onPress(() { - final genderValue = isArabic - ? GenderTypeEnum.values[index].typeAr - : GenderTypeEnum.values[index].type; + final genderValue = + // isArabic + // ? GenderTypeEnum.values[index].typeAr + + // : + GenderTypeEnum.values[index].type; bloodDonationViewModel.onGenderChange(genderValue); Navigator.of(context).pop(); })); diff --git a/lib/presentation/home/landing_page.dart b/lib/presentation/home/landing_page.dart index 531b310a..85f1e972 100644 --- a/lib/presentation/home/landing_page.dart +++ b/lib/presentation/home/landing_page.dart @@ -448,31 +448,31 @@ class _LandingPageState extends State { : SizedBox.shrink(), // Offers And Discounts Carousel - Auto-scrolling from right to left - appState.isAuthenticated && appState.isEnabledOffersAndDiscountsCarousel - ? Column( - children: [ - SizedBox(height: 12.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ("${LocaleKeys.offers.tr(context: context)} & ${LocaleKeys.discount.tr(context: context)}").toText16(isBold: true), - Row( - children: [ - LocaleKeys.viewAll.tr(context: context).toText14(color: AppColors.primaryRedColor, isBold: true), - SizedBox(width: 2.h), - Icon(Icons.arrow_forward_ios, color: AppColors.primaryRedColor, size: 14.h), - ], - ), - ], - ).paddingSymmetrical(24.h, 0.h).onPress(() { - Navigator.of(context).push(CustomPageRoute(page: OffersAndDiscountsPage())); - }), - SizedBox(height: 16.h), - OffersAndDiscountsCarousel().paddingSymmetrical(24.h, 0.h), - SizedBox(height: 18.h), - ], - ) - : SizedBox.shrink(), + // appState.isAuthenticated && appState.isEnabledOffersAndDiscountsCarousel + // ? Column( + // children: [ + // SizedBox(height: 12.h), + // Row( + // mainAxisAlignment: MainAxisAlignment.spaceBetween, + // children: [ + // ("${LocaleKeys.offers.tr(context: context)} & ${LocaleKeys.discount.tr(context: context)}").toText16(isBold: true), + // Row( + // children: [ + // LocaleKeys.viewAll.tr(context: context).toText14(color: AppColors.primaryRedColor, isBold: true), + // SizedBox(width: 2.h), + // Icon(Icons.arrow_forward_ios, color: AppColors.primaryRedColor, size: 14.h), + // ], + // ), + // ], + // ).paddingSymmetrical(24.h, 0.h).onPress(() { + // Navigator.of(context).push(CustomPageRoute(page: OffersAndDiscountsPage())); + // }), + // SizedBox(height: 16.h), + // OffersAndDiscountsCarousel().paddingSymmetrical(24.h, 0.h), + // SizedBox(height: 18.h), + // ], + // ) + // : SizedBox.shrink(), appState.isAuthenticated ? Column( @@ -516,7 +516,7 @@ class _LandingPageState extends State { ), ).paddingSymmetrical(24.h, 16.h) : myAppointmentsVM.patientAppointmentsHistoryList.isNotEmpty - ? myAppointmentsVM.patientAppointmentsHistoryList.length == 1 + ? myAppointmentsVM.patientAppointmentsHistoryList.length == 1 && !immediateLiveCareVM.patientHasPendingLiveCareRequest ? Container( decoration: RoundedRectangleBorder().toSmoothCornerDecoration( color: AppColors.whiteColor, @@ -536,9 +536,11 @@ class _LandingPageState extends State { height: isTablet ? 290.h : 255.h, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: myAppointmentsVM.patientAppointmentsHistoryList.length < 3 - ? myAppointmentsVM.patientAppointmentsHistoryList.length - : 3, + itemCount: () { + final int baseCount = + myAppointmentsVM.patientAppointmentsHistoryList.length + (immediateLiveCareVM.patientHasPendingLiveCareRequest ? 1 : 0); + return baseCount < 3 ? baseCount : 3; + }(), shrinkWrap: true, padding: EdgeInsets.only(left: 16.h, right: 16.h), itemBuilder: (context, index) { @@ -571,10 +573,14 @@ class _LandingPageState extends State { height: 255.h + 20 + 30, // itemHeight + shadow padding (10 top + 10 bottom) + pagination dots space child: Builder( builder: (context) { + final int baseAppointmentCount = myAppointmentsVM.patientAppointmentsHistoryList.length; + final int liveCareCount = immediateLiveCareVM.patientHasPendingLiveCareRequest ? 1 : 0; + final int totalCount = baseAppointmentCount + liveCareCount; + final int swiperItemCount = myAppointmentsVM.isMyAppointmentsLoading ? 3 - : myAppointmentsVM.patientAppointmentsHistoryList.length < 3 - ? myAppointmentsVM.patientAppointmentsHistoryList.length + : totalCount < 3 + ? totalCount : 3; final bool isArabic = appState.isArabic(); return Swiper( From b5d95a5a74b8248bffb107110d248de8639b5c14 Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Thu, 25 Jun 2026 17:15:29 +0300 Subject: [PATCH 04/10] updates --- lib/features/payfort/payfort_view_model.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/features/payfort/payfort_view_model.dart b/lib/features/payfort/payfort_view_model.dart index fd0cfe10..81d66de2 100644 --- a/lib/features/payfort/payfort_view_model.dart +++ b/lib/features/payfort/payfort_view_model.dart @@ -6,13 +6,13 @@ import 'package:amazon_payfort/amazon_payfort.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:hmg_patient_app_new/core/api_consts.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; import 'package:hmg_patient_app_new/core/cache_consts.dart'; import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/get_tamara_installments_details_response_model.dart'; -import 'package:hmg_patient_app_new/features/payfort/models/apple_pay_request_insert_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/payfort_check_payment_status_response_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/payfort_project_details_resp_model.dart'; import 'package:hmg_patient_app_new/features/payfort/models/payfort_request_insert_model.dart'; @@ -24,6 +24,8 @@ import 'package:hmg_patient_app_new/widgets/loader/bottomsheet_loader.dart'; import 'package:network_info_plus/network_info_plus.dart'; import 'package:crypto/crypto.dart'; +import 'models/apple_pay_request_insert_model.dart'; + class PayfortViewModel extends ChangeNotifier { PayfortRepo payfortRepo; ErrorHandlerService errorHandlerService; From eafb7aa10580a6b9660853c64ed47f058ffe3b79 Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Sat, 27 Jun 2026 22:55:58 +0300 Subject: [PATCH 05/10] updates --- .../svg/doctor_profile_schedule_icon.svg | 9 ++++ assets/langs/ar-SA.json | 3 +- assets/langs/en-US.json | 3 +- lib/core/api_consts.dart | 2 +- lib/core/app_assets.dart | 1 + .../habib_wallet/habib_wallet_view_model.dart | 2 +- lib/features/payfort/payfort_view_model.dart | 1 - lib/generated/locale_keys.g.dart | 1 + .../book_appointment/doctor_profile_page.dart | 9 ++++ .../habib_wallet/habib_wallet_page.dart | 37 ++++++++++--- .../widgets/wallet_balance_list.dart | 54 +++++++++++++++++++ lib/widgets/app_language_change.dart | 1 + pubspec.yaml | 2 +- 13 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 assets/images/svg/doctor_profile_schedule_icon.svg create mode 100644 lib/presentation/habib_wallet/widgets/wallet_balance_list.dart diff --git a/assets/images/svg/doctor_profile_schedule_icon.svg b/assets/images/svg/doctor_profile_schedule_icon.svg new file mode 100644 index 00000000..20e36299 --- /dev/null +++ b/assets/images/svg/doctor_profile_schedule_icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index 0f434598..b69827d4 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -1960,5 +1960,6 @@ "parkingQR": "رمز الاستجابة السريعة لوقوف السيارات", "parkingQRDesc": "امسح رمز الاستجابة السريعة (QR Code) أدناه عند بوابة الخروج من موقف السيارات:", "livecareUpdateInsuranceSent": "تم استلام طلب تحديث بيانات التأمين الخاص بك، ويجري العمل عليه حالياً. سنقوم بإشعارك فور اكتمال التحديث.", - "livecareUpdateInsurance": "سيتم تحويل طلب تحديث بيانات التأمين إلى فريق LiveCare المختص لاستكمال الإجراءات. هل تود المتابعة؟" + "livecareUpdateInsurance": "سيتم تحويل طلب تحديث بيانات التأمين إلى فريق LiveCare المختص لاستكمال الإجراءات. هل تود المتابعة؟", + "schedule": "جدول" } diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index cedcd40a..bb5b5fe4 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -1949,7 +1949,8 @@ "parkingQR": "Parking QR Code", "parkingQRDesc": "Scan the below QR Code at the exit parking gate:", "livecareUpdateInsuranceSent": "Your insurance update request is being processed. You will be notified once the update is completed.", - "livecareUpdateInsurance": "Please note that your insurance update request will be sent to the our LiveCare team for update. Would you like to proceed?" + "livecareUpdateInsurance": "Please note that your insurance update request will be sent to the our LiveCare team for update. Would you like to proceed?", + "schedule": "Schedule" } diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index 04d0c962..664432fd 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -5,7 +5,7 @@ import 'package:hmg_patient_app_new/core/enums.dart'; class ApiConsts { static const maxSmallScreen = 660; - static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.uat; + static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.prod; // static String baseUrl = 'https://uat.hmgwebservices.com/'; // HIS API URL UAT diff --git a/lib/core/app_assets.dart b/lib/core/app_assets.dart index 2e27d633..aba22eb3 100644 --- a/lib/core/app_assets.dart +++ b/lib/core/app_assets.dart @@ -193,6 +193,7 @@ class AppAssets { static const String ic_rrt_vehicle = '$svgBasePath/ic_rrt_vehicle.svg'; static const String doctor_profile_rating_icon = '$svgBasePath/doctor_profile_rating_icon.svg'; static const String doctor_profile_reviews_icon = '$svgBasePath/doctor_profile_reviews_icon.svg'; + static const String doctor_profile_schedule_icon = '$svgBasePath/doctor_profile_schedule_icon.svg'; static const String bookmark_icon = '$svgBasePath/bookmark_icon.svg'; static const String bookmark_filled_icon = '$svgBasePath/bookmark_filled_icon.svg'; static const String waiting_appointment_icon = '$svgBasePath/waitingAppo.svg'; diff --git a/lib/features/habib_wallet/habib_wallet_view_model.dart b/lib/features/habib_wallet/habib_wallet_view_model.dart index 3bee29de..cc87bb7b 100644 --- a/lib/features/habib_wallet/habib_wallet_view_model.dart +++ b/lib/features/habib_wallet/habib_wallet_view_model.dart @@ -335,7 +335,6 @@ class HabibWalletViewModel extends ChangeNotifier { walletRechargeAmount = 0; selectedRechargeType = 1; advancePaymentHospitals.clear(); - habibWalletBalanceList.clear(); selectedHospital = null; fileNumber = ''; depositorName = ''; @@ -417,6 +416,7 @@ class HabibWalletViewModel extends ChangeNotifier { if (!isWalletAmountToBeLoaded) { return; } + habibWalletBalanceList.clear(); isWalletAmountLoading = true; notifyListeners(); diff --git a/lib/features/payfort/payfort_view_model.dart b/lib/features/payfort/payfort_view_model.dart index 81d66de2..f567264f 100644 --- a/lib/features/payfort/payfort_view_model.dart +++ b/lib/features/payfort/payfort_view_model.dart @@ -6,7 +6,6 @@ import 'package:amazon_payfort/amazon_payfort.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; -import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:hmg_patient_app_new/core/api_consts.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; import 'package:hmg_patient_app_new/core/cache_consts.dart'; diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 509fc86a..c82e2d12 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -1942,5 +1942,6 @@ abstract class LocaleKeys { static const parkingQRDesc = 'parkingQRDesc'; static const livecareUpdateInsuranceSent = 'livecareUpdateInsuranceSent'; static const livecareUpdateInsurance = 'livecareUpdateInsurance'; + static const schedule = 'schedule'; } diff --git a/lib/presentation/book_appointment/doctor_profile_page.dart b/lib/presentation/book_appointment/doctor_profile_page.dart index be097651..f54f95bf 100644 --- a/lib/presentation/book_appointment/doctor_profile_page.dart +++ b/lib/presentation/book_appointment/doctor_profile_page.dart @@ -192,6 +192,15 @@ class DoctorProfilePage extends StatelessWidget { isCloseButtonVisible: true, ); }), + SizedBox(width: 36.w), + Column( + children: [ + Utils.buildSvgWithAssets(icon: AppAssets.doctor_profile_schedule_icon, width: 48.w, height: 48.h, fit: BoxFit.contain, applyThemeColor: false), + SizedBox(height: 16.h), + LocaleKeys.schedule.tr(context: context).toText12(isBold: true, color: AppColors.greyTextColor), + LocaleKeys.view.tr(context: context).toText16(isBold: true, color: AppColors.textColor, isUnderLine: true, decorationColor: AppColors.textColor, fontFamily: "Poppins"), + ], + ).onPress(() {}) ], ), SizedBox(height: 16.h), diff --git a/lib/presentation/habib_wallet/habib_wallet_page.dart b/lib/presentation/habib_wallet/habib_wallet_page.dart index c4ee9356..07df55ea 100644 --- a/lib/presentation/habib_wallet/habib_wallet_page.dart +++ b/lib/presentation/habib_wallet/habib_wallet_page.dart @@ -14,6 +14,7 @@ import 'package:hmg_patient_app_new/features/habib_wallet/habib_wallet_view_mode import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/presentation/habib_wallet/recharge_wallet_page.dart'; +import 'package:hmg_patient_app_new/presentation/habib_wallet/widgets/wallet_balance_list.dart'; import 'package:hmg_patient_app_new/presentation/habib_wallet/withdraw_request_create_page.dart'; import 'package:hmg_patient_app_new/presentation/lab/lab_result_item_view.dart'; import 'package:hmg_patient_app_new/presentation/my_invoices/widgets/invoice_list_card.dart'; @@ -97,15 +98,39 @@ class _HabibWalletState extends State { Spacer(), LocaleKeys.balanceAmount.tr(context: context).toText14(isBold: true, color: Colors.white), SizedBox(height: 4.h), - Consumer(builder: (context, habibWalletVM, child) { - return Utils.getPaymentAmountWithSymbol2(habibWalletVM.habibWalletAmount, - textColor: Colors.white, iconColor: Colors.white, iconSize: 16, isExpanded: false, letterSpacing: -1) - .toShimmer2(isShow: habibWalletVM.isWalletAmountLoading, radius: 12.h, width: 80.h, height: 24.h); - }), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Consumer(builder: (context, habibWalletVM, child) { + return Utils.getPaymentAmountWithSymbol2(habibWalletVM.habibWalletAmount, + textColor: Colors.white, iconColor: Colors.white, iconSize: 16, isExpanded: false, letterSpacing: -1) + .toShimmer2(isShow: habibWalletVM.isWalletAmountLoading, radius: 12.h, width: 80.h, height: 24.h); + }), + Transform.flip( + flipX: appState.isArabic(), + child: Utils.buildSvgWithAssets( + icon: AppAssets.arrow_forward, + height: 24.h, + width: 24.h, + iconColor: AppColors.whiteColor, + ), + ), + ], + ), ], ), ), - ), + ).onPress(() { + showCommonBottomSheetWithoutHeight( + title: LocaleKeys.habibWallet.tr(context: context), + context, + // child: Utils.getSuccessWidget(loadingText: LocaleKeys.erAppointmentBookedSuccess.tr(context: context)), + child: HabibWalletBalanceList(), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }), SizedBox(height: 16.h), Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/presentation/habib_wallet/widgets/wallet_balance_list.dart b/lib/presentation/habib_wallet/widgets/wallet_balance_list.dart new file mode 100644 index 00000000..62817e98 --- /dev/null +++ b/lib/presentation/habib_wallet/widgets/wallet_balance_list.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/utils/size_utils.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/habib_wallet/habib_wallet_view_model.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:provider/provider.dart'; + +class HabibWalletBalanceList extends StatelessWidget { + const HabibWalletBalanceList({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.r, + hasShadow: false, + ), + child: Consumer(builder: (context, habibWalletVM, child) { + return ListView.separated( + itemCount: habibWalletVM.habibWalletBalanceList.length, + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.only(top: 8, bottom: 8), + shrinkWrap: true, + itemBuilder: (context, index) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: habibWalletVM.habibWalletBalanceList[index].projectDescription!.toText16(isBold: true, color: AppColors.textColor)), + Utils.getPaymentAmountWithSymbol2(habibWalletVM.habibWalletBalanceList[index].patientAdvanceBalanceAmount!, //did the changes because of negative value + textColor: AppColors.textColor, + iconColor: AppColors.textColor, + iconSize: 18.h, + isExpanded: false, + fontSize: 28.f, + fontWeight: FontWeight.w600, + letterSpacing: -1), + ], + ).paddingSymmetrical(0, 12.h); + }, + separatorBuilder: (BuildContext context, int index) { + return Divider(height: 1, color: AppColors.textColor.withAlpha(20)); + }, + ).paddingSymmetrical(16.h, 24.h).toShimmer2(isShow: habibWalletVM.isWalletAmountLoading); + }), + ), + ], + ); + } +} diff --git a/lib/widgets/app_language_change.dart b/lib/widgets/app_language_change.dart index ff8f9c59..c2ae0272 100644 --- a/lib/widgets/app_language_change.dart +++ b/lib/widgets/app_language_change.dart @@ -75,6 +75,7 @@ class _AppLanguageChangeState extends State { myAppointmentsViewModel.initAppointmentsViewModel(); myAppointmentsViewModel.getPatientAppointments(true, false); + habibWalletVM.setIsWalletAmountToBeLoaded(true); habibWalletVM.initHabibWalletProvider(); habibWalletVM.getPatientBalanceAmount(); habibWalletVM.getLakumAccountInformation(); diff --git a/pubspec.yaml b/pubspec.yaml index 880ec18e..ba0c0ed5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -87,7 +87,7 @@ dependencies: open_filex: ^4.7.0 flutter_swiper_view: path: package/flutter_swiper_view - flutter_callkit_incoming: ^3.0.0 + flutter_callkit_incoming: 3.0.0 in_app_update: ^4.2.5 # device_preview: ^1.3.1 image_picker_android: ^0.8.13+16 From be00b29bbb57b8b85b40c124e141e536baaf06f3 Mon Sep 17 00:00:00 2001 From: Sultan khan Date: Sun, 28 Jun 2026 16:18:44 +0300 Subject: [PATCH 06/10] no message --- DYNAMIC_THEME_IMPLEMENTATION_PLAN.md | 1836 ----------------- assets/langs/ar-SA.json | 11 +- assets/langs/en-US.json | 11 +- .../book_appointments_repo.dart | 47 + .../book_appointments_view_model.dart | 44 + .../doctor_schedule_response_model.dart | 26 + lib/generated/locale_keys.g.dart | 4 + .../book_appointment/doctor_profile_page.dart | 77 +- .../widgets/doctor_schedule_bottom_sheet.dart | 400 ++++ .../widgets/doctor_schedule_page.dart | 39 + 10 files changed, 652 insertions(+), 1843 deletions(-) delete mode 100644 DYNAMIC_THEME_IMPLEMENTATION_PLAN.md create mode 100644 lib/features/book_appointments/models/resp_models/doctor_schedule_response_model.dart create mode 100644 lib/presentation/book_appointment/widgets/doctor_schedule_bottom_sheet.dart create mode 100644 lib/presentation/book_appointment/widgets/doctor_schedule_page.dart diff --git a/DYNAMIC_THEME_IMPLEMENTATION_PLAN.md b/DYNAMIC_THEME_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 6f10744f..00000000 --- a/DYNAMIC_THEME_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,1836 +0,0 @@ -# Dynamic Multi-Theme Implementation Plan -## HMG Patient App - Firebase Remote Config Driven Themes - -> **🔥 Firebase-First Approach**: All theme configurations, colors, dates, and features are controlled via Firebase Remote Config. Zero app updates needed for new events! - ---- - -## 🎯 Quick Overview - -### What We're Building -A dynamic theming system that allows you to change your app's appearance for special events (National Day, Ramadan, Eid, etc.) without deploying new app versions. Everything is controlled via Firebase Remote Config. - -### Why Firebase Remote Config? -- ✅ **Instant Updates**: Change themes without app store approval -- ✅ **A/B Testing**: Test different theme variations -- ✅ **Gradual Rollout**: Deploy to 10%, 50%, 100% of users -- ✅ **Instant Rollback**: Disable themes immediately if needed -- ✅ **Targeting**: Different themes for different regions/languages -- ✅ **No Code Changes**: Add unlimited new events via JSON - -### Example: National Day 2026 -``` -September 19, 2026 (Firebase Console): -1. Create JSON config with Saudi green colors -2. Set dates: Sept 20-24 -3. Click "Publish" -4. ✨ ALL users see green theme within 1 hour! - -September 25, 2026: -- Theme automatically deactivates (date passed) -- App returns to normal colors -- Zero developer intervention -``` - ---- - -# Dynamic Multi-Theme Implementation Plan -## HMG Patient App - National Day & International Events Theming - ---- - -## 📋 Executive Summary - -**Goal**: Implement dynamic event-based themes (National Day, Eid, New Year, etc.) while maintaining the existing Light/Dark mode architecture with minimal changes, controlled via Firebase Remote Config. - -**Strategy**: Extend the current dual-theme system to support a **Theme Layer System** where event themes override base colors while preserving dark mode functionality. All theme activation, dates, colors, and features are controlled remotely via Firebase. - -**Impact**: -- ✅ Zero breaking changes to existing code -- ✅ Maintains current dark/light mode toggle -- ✅ Supports unlimited event themes -- ✅ **100% controlled via Firebase Remote Config** (no app updates needed) -- ✅ Real-time theme activation/deactivation -- ✅ A/B testing support for event themes -- ✅ Instant rollback capability - ---- - -## 🏗️ Architecture Overview - -### Current Architecture (Preserved) -``` -User Toggle → ProfileSettingsViewModel → AppColors.isDarkMode → Theme Switch -``` - -### New Architecture (Firebase-Driven) -``` -Firebase Remote Config → RemoteConfigService → EventThemeManager → Active Event Theme - ↓ - Color Overlay - ↓ -User Toggle → ProfileSettingsViewModel → Dark/Light Mode → AppColors Resolution → Final UI -``` - -### Data Flow -``` -1. App Startup → Fetch Remote Config -2. Parse Active Event Themes (JSON) -3. Check Current Date/Time -4. Activate Highest Priority Theme -5. Apply Color Overrides -6. Listen for Config Updates (Real-time) -``` - ---- - -## 📁 File Structure Changes - -### New Files to Create (9 files) -``` -lib/ -├── theme/ -│ ├── app_theme.dart # [MODIFY] - Add event theme support -│ ├── colors.dart # [MODIFY] - Add event color override system -│ ├── event_themes/ # [NEW DIRECTORY] -│ │ ├── event_theme_config.dart # [NEW] - Event theme definitions from Remote Config -│ │ ├── event_theme_manager.dart # [NEW] - Theme activation logic -│ │ ├── remote_config_service.dart # [NEW] - Firebase Remote Config wrapper -│ │ ├── themes/ # [NEW DIRECTORY] -│ │ │ ├── base_event_theme.dart # [NEW] - Abstract base class -│ │ │ ├── dynamic_event_theme.dart # [NEW] - Dynamic theme from Remote Config -│ │ └── models/ -│ │ ├── event_theme_model.dart # [NEW] - Event metadata model -│ │ └── remote_theme_config.dart # [NEW] - Remote Config data model -├── features/ -│ └── profile_settings/ -│ └── profile_settings_view_model.dart # [MODIFY] - Add event theme state -└── core/ - └── app_state.dart # [MODIFY] - Add event theme flags -``` - -### Modified Files (4 files) -- `lib/theme/app_theme.dart` - Add event theme injection -- `lib/theme/colors.dart` - Add event color override getters -- `lib/features/profile_settings/profile_settings_view_model.dart` - Add event theme state -- `lib/main.dart` - Initialize theme manager and Remote Config - -### Firebase Remote Config Keys -- `event_themes_config` - JSON array of active event themes -- `enable_event_themes` - Global on/off switch -- `debug_event_theme` - Force specific theme for testing - ---- - -## 🎨 Design Pattern: Theme Layer System - -### Layer Hierarchy (Bottom to Top) -``` -1. Base Light Colors (Always present) - ↓ -2. Base Dark Colors (When isDarkMode = true) - ↓ -3. Event Theme Colors (When active event exists) - ↓ -4. Final Rendered Color -``` - -### Color Resolution Logic -```dart -Color resolveColor() { - if (hasActiveEvent && eventTheme.overridesThisColor) { - return eventTheme.getColor(isDarkMode); // Event-specific color - } - return isDarkMode ? darkColor : lightColor; // Standard color -} -``` - ---- - -## 🔧 Implementation Steps - -### **Phase 1: Firebase Remote Config Setup (Day 1-2)** - -#### Step 1.0: Firebase Remote Config Service - -**File**: `lib/theme/event_themes/remote_config_service.dart` (NEW) -```dart -import 'dart:convert'; -import 'package:firebase_remote_config/firebase_remote_config.dart'; -import 'package:flutter/material.dart'; -import 'package:hmg_patient_app_new/services/logger_service.dart'; - -class RemoteConfigService extends ChangeNotifier { - static RemoteConfigService? _instance; - factory RemoteConfigService() => _instance ??= RemoteConfigService._internal(); - RemoteConfigService._internal(); - - final FirebaseRemoteConfig _remoteConfig = FirebaseRemoteConfig.instance; - final LoggerService _logger = LoggerService(); - - bool _isInitialized = false; - bool get isInitialized => _isInitialized; - - // Remote Config Keys - static const String _eventThemesConfigKey = 'event_themes_config'; - static const String _enableEventThemesKey = 'enable_event_themes'; - static const String _debugEventThemeKey = 'debug_event_theme'; - - /// Initialize Remote Config with defaults - Future initialize() async { - try { - await _remoteConfig.setConfigSettings(RemoteConfigSettings( - fetchTimeout: const Duration(minutes: 1), - minimumFetchInterval: const Duration(hours: 1), - )); - - // Set default values - await _remoteConfig.setDefaults({ - _eventThemesConfigKey: _getDefaultThemesConfig(), - _enableEventThemesKey: true, - _debugEventThemeKey: '', - }); - - // Fetch and activate - await _remoteConfig.fetchAndActivate(); - - _isInitialized = true; - _logger.logInfo('Remote Config initialized successfully'); - - // Listen for real-time updates - _remoteConfig.onConfigUpdated.listen((event) async { - await _remoteConfig.activate(); - _logger.logInfo('Remote Config updated'); - notifyListeners(); // Notify listeners of config changes - }); - - } catch (e) { - _logger.logError('Remote Config initialization failed: $e'); - _isInitialized = false; - } - } - - /// Get event themes configuration from Remote Config - List> getEventThemesConfig() { - try { - final jsonString = _remoteConfig.getString(_eventThemesConfigKey); - if (jsonString.isEmpty) return []; - - final decoded = jsonDecode(jsonString); - if (decoded is List) { - return List>.from( - decoded.map((item) => Map.from(item)) - ); - } - return []; - } catch (e) { - _logger.logError('Error parsing event themes config: $e'); - return []; - } - } - - /// Check if event themes are globally enabled - bool areEventThemesEnabled() { - return _remoteConfig.getBool(_enableEventThemesKey); - } - - /// Get debug theme (for testing) - String? getDebugEventTheme() { - final value = _remoteConfig.getString(_debugEventThemeKey); - return value.isEmpty ? null : value; - } - - /// Force refresh config (call when app comes to foreground) - Future refresh() async { - try { - await _remoteConfig.fetchAndActivate(); - notifyListeners(); - } catch (e) { - _logger.logError('Remote Config refresh failed: $e'); - } - } - - /// Default configuration (fallback) - String _getDefaultThemesConfig() { - return jsonEncode([ - // Default: No active themes - // Themes are activated via Firebase Console - ]); - } -} -``` - -#### Step 1.1: Remote Theme Configuration Model - -**File**: `lib/theme/event_themes/models/remote_theme_config.dart` (NEW) -```dart -import 'package:flutter/material.dart'; - -/// Model for event theme configuration from Firebase Remote Config -class RemoteThemeConfig { - final String id; - final String type; // 'nationalDay', 'eid', 'ramadan', etc. - final String nameEn; - final String nameAr; - final DateTime startDate; - final DateTime endDate; - final bool isActive; - final int priority; - - // Color overrides (hex strings from Remote Config) - final String? primaryColor; - final String? primaryColorDark; - final String? secondaryColor; - final String? secondaryColorDark; - final String? accentColor; - final String? accentColorDark; - final String? scaffoldBgColor; - final String? scaffoldBgColorDark; - final String? cardBgColor; - final String? cardBgColorDark; - final String? appBarColor; - final String? appBarColorDark; - - // Gradient configuration - final List? gradientColors; - final String? gradientStart; // 'topLeft', 'topCenter', etc. - final String? gradientEnd; - - // Special features - final String? bannerImageUrl; - final bool showConfetti; - final bool enableAnimations; - - RemoteThemeConfig({ - required this.id, - required this.type, - required this.nameEn, - required this.nameAr, - required this.startDate, - required this.endDate, - required this.isActive, - required this.priority, - this.primaryColor, - this.primaryColorDark, - this.secondaryColor, - this.secondaryColorDark, - this.accentColor, - this.accentColorDark, - this.scaffoldBgColor, - this.scaffoldBgColorDark, - this.cardBgColor, - this.cardBgColorDark, - this.appBarColor, - this.appBarColorDark, - this.gradientColors, - this.gradientStart, - this.gradientEnd, - this.bannerImageUrl, - this.showConfetti = false, - this.enableAnimations = false, - }); - - /// Create from Remote Config JSON - factory RemoteThemeConfig.fromJson(Map json) { - return RemoteThemeConfig( - id: json['id'] ?? '', - type: json['type'] ?? '', - nameEn: json['nameEn'] ?? '', - nameAr: json['nameAr'] ?? '', - startDate: DateTime.parse(json['startDate'] ?? DateTime.now().toIso8601String()), - endDate: DateTime.parse(json['endDate'] ?? DateTime.now().toIso8601String()), - isActive: json['isActive'] ?? false, - priority: json['priority'] ?? 0, - primaryColor: json['primaryColor'], - primaryColorDark: json['primaryColorDark'], - secondaryColor: json['secondaryColor'], - secondaryColorDark: json['secondaryColorDark'], - accentColor: json['accentColor'], - accentColorDark: json['accentColorDark'], - scaffoldBgColor: json['scaffoldBgColor'], - scaffoldBgColorDark: json['scaffoldBgColorDark'], - cardBgColor: json['cardBgColor'], - cardBgColorDark: json['cardBgColorDark'], - appBarColor: json['appBarColor'], - appBarColorDark: json['appBarColorDark'], - gradientColors: json['gradientColors'] != null - ? List.from(json['gradientColors']) - : null, - gradientStart: json['gradientStart'], - gradientEnd: json['gradientEnd'], - bannerImageUrl: json['bannerImageUrl'], - showConfetti: json['showConfetti'] ?? false, - enableAnimations: json['enableAnimations'] ?? false, - ); - } - - /// Check if theme should be active based on dates - bool get shouldActivate { - final now = DateTime.now(); - return now.isAfter(startDate) && - now.isBefore(endDate) && - isActive; - } - - /// Parse hex color string to Color - Color? parseColor(String? hexString) { - if (hexString == null || hexString.isEmpty) return null; - - try { - final hex = hexString.replaceAll('#', ''); - if (hex.length == 6) { - return Color(int.parse('FF$hex', radix: 16)); - } else if (hex.length == 8) { - return Color(int.parse(hex, radix: 16)); - } - } catch (e) { - // Invalid color format - } - return null; - } - - /// Get gradient alignment from string - Alignment _parseAlignment(String? alignment) { - switch (alignment?.toLowerCase()) { - case 'topleft': return Alignment.topLeft; - case 'topcenter': return Alignment.topCenter; - case 'topright': return Alignment.topRight; - case 'centerleft': return Alignment.centerLeft; - case 'center': return Alignment.center; - case 'centerright': return Alignment.centerRight; - case 'bottomleft': return Alignment.bottomLeft; - case 'bottomcenter': return Alignment.bottomCenter; - case 'bottomright': return Alignment.bottomRight; - default: return Alignment.topLeft; - } - } - - /// Build gradient from configuration - LinearGradient? get gradient { - if (gradientColors == null || gradientColors!.length < 2) return null; - - final colors = gradientColors! - .map((hex) => parseColor(hex)) - .whereType() - .toList(); - - if (colors.length < 2) return null; - - return LinearGradient( - colors: colors, - begin: _parseAlignment(gradientStart), - end: _parseAlignment(gradientEnd), - ); - } -} -``` - -#### Step 1.2: Create Base Event Theme System - -**File**: `lib/theme/event_themes/models/event_theme_model.dart` -```dart -enum EventType { - nationalDay, - eid, - newYear, - ramadan, - foundingDay, - motherDay, - custom -} - -class EventThemeModel { - final String id; - final EventType type; - final String nameEn; - final String nameAr; - final DateTime startDate; - final DateTime endDate; - final bool isActive; - final int priority; // Higher number = higher priority - - // Auto-activate based on current date - bool get shouldActivate { - final now = DateTime.now(); - return now.isAfter(startDate) && - now.isBefore(endDate) && - isActive; - } -} -``` - -**File**: `lib/theme/event_themes/themes/base_event_theme.dart` -```dart -abstract class BaseEventTheme { - EventThemeModel get metadata; - - // Colors that override base theme - Color? get primaryBrandColor; - Color? get secondaryBrandColor; - Color? get accentColor; - Color? get scaffoldBackgroundColor; - Color? get cardBackgroundColor; - Color? get appBarColor; - - // Dark mode variants (optional) - Color? get primaryBrandColorDark; - Color? get secondaryBrandColorDark; - Color? get accentColorDark; - Color? get scaffoldBackgroundColorDark; - Color? get cardBackgroundColorDark; - Color? get appBarColorDark; - - // Special features (optional) - LinearGradient? get backgroundGradient; - String? get celebrationBannerImage; - bool get showConfetti; - bool get enableSpecialAnimations; - - // Method to get color with dark mode support - Color? getColor(String colorKey, bool isDarkMode) { - // Implementation in concrete classes - } -} -``` - -#### Step 1.3: Dynamic Event Theme from Remote Config - -**File**: `lib/theme/event_themes/themes/dynamic_event_theme.dart` (NEW) -```dart -import 'package:flutter/material.dart'; -import 'package:hmg_patient_app_new/theme/event_themes/models/remote_theme_config.dart'; -import 'package:hmg_patient_app_new/theme/event_themes/themes/base_event_theme.dart'; -import 'package:hmg_patient_app_new/theme/event_themes/models/event_theme_model.dart'; - -/// Dynamic event theme created from Remote Config -class DynamicEventTheme extends BaseEventTheme { - final RemoteThemeConfig config; - - DynamicEventTheme(this.config); - - @override - EventThemeModel get metadata => EventThemeModel( - id: config.id, - type: _parseEventType(config.type), - nameEn: config.nameEn, - nameAr: config.nameAr, - startDate: config.startDate, - endDate: config.endDate, - isActive: config.isActive, - priority: config.priority, - ); - - EventType _parseEventType(String type) { - switch (type.toLowerCase()) { - case 'nationalday': return EventType.nationalDay; - case 'eid': return EventType.eid; - case 'ramadan': return EventType.ramadan; - case 'newyear': return EventType.newYear; - case 'foundingday': return EventType.foundingDay; - case 'motherday': return EventType.motherDay; - default: return EventType.custom; - } - } - - // Color overrides from Remote Config - @override - Color? get primaryBrandColor => config.parseColor(config.primaryColor); - - @override - Color? get primaryBrandColorDark => config.parseColor(config.primaryColorDark); - - @override - Color? get secondaryBrandColor => config.parseColor(config.secondaryColor); - - @override - Color? get secondaryBrandColorDark => config.parseColor(config.secondaryColorDark); - - @override - Color? get accentColor => config.parseColor(config.accentColor); - - @override - Color? get accentColorDark => config.parseColor(config.accentColorDark); - - @override - Color? get scaffoldBackgroundColor => config.parseColor(config.scaffoldBgColor); - - @override - Color? get scaffoldBackgroundColorDark => config.parseColor(config.scaffoldBgColorDark); - - @override - Color? get cardBackgroundColor => config.parseColor(config.cardBgColor); - - @override - Color? get cardBackgroundColorDark => config.parseColor(config.cardBgColorDark); - - @override - Color? get appBarColor => config.parseColor(config.appBarColor); - - @override - Color? get appBarColorDark => config.parseColor(config.appBarColorDark); - - // Special features from Remote Config - @override - LinearGradient? get backgroundGradient => config.gradient; - - @override - String? get celebrationBannerImage => config.bannerImageUrl; - - @override - bool get showConfetti => config.showConfetti; - - @override - bool get enableSpecialAnimations => config.enableAnimations; - - @override - Color? getColor(String colorKey, bool isDarkMode) { - switch (colorKey) { - case 'primaryBrand': - return isDarkMode ? primaryBrandColorDark ?? primaryBrandColor : primaryBrandColor; - case 'secondaryBrand': - return isDarkMode ? secondaryBrandColorDark ?? secondaryBrandColor : secondaryBrandColor; - case 'accent': - return isDarkMode ? accentColorDark ?? accentColor : accentColor; - case 'scaffoldBackground': - return isDarkMode ? scaffoldBackgroundColorDark ?? scaffoldBackgroundColor : scaffoldBackgroundColor; - case 'cardBackground': - return isDarkMode ? cardBackgroundColorDark ?? cardBackgroundColor : cardBackgroundColor; - case 'appBar': - return isDarkMode ? appBarColorDark ?? appBarColor : appBarColor; - default: - return null; - } - } -} -``` - -#### Step 1.4: Create Event Theme Manager (Remote Config Integration) - -**File**: `lib/theme/event_themes/event_theme_manager.dart` -```dart -import 'package:flutter/material.dart'; -import 'package:hmg_patient_app_new/theme/event_themes/remote_config_service.dart'; -import 'package:hmg_patient_app_new/theme/event_themes/models/remote_theme_config.dart'; -import 'package:hmg_patient_app_new/theme/event_themes/themes/base_event_theme.dart'; -import 'package:hmg_patient_app_new/theme/event_themes/themes/dynamic_event_theme.dart'; -import 'package:hmg_patient_app_new/services/logger_service.dart'; - -class EventThemeManager extends ChangeNotifier { - static EventThemeManager? _instance; - factory EventThemeManager() => _instance ??= EventThemeManager._internal(); - EventThemeManager._internal(); - - final RemoteConfigService _remoteConfigService = RemoteConfigService(); - final LoggerService _logger = LoggerService(); - - BaseEventTheme? _activeEventTheme; - List _availableThemes = []; - bool _isInitialized = false; - - BaseEventTheme? get activeEventTheme => _activeEventTheme; - bool get hasActiveEvent => _activeEventTheme != null; - bool get isInitialized => _isInitialized; - - /// Initialize with Remote Config - Future initialize() async { - try { - // Initialize Remote Config first - await _remoteConfigService.initialize(); - - // Listen to Remote Config updates - _remoteConfigService.addListener(_onRemoteConfigUpdated); - - // Load themes from Remote Config - await _loadThemesFromRemoteConfig(); - - _isInitialized = true; - _logger.logInfo('EventThemeManager initialized successfully'); - } catch (e) { - _logger.logError('EventThemeManager initialization failed: $e'); - _isInitialized = false; - } - } - - /// Load themes from Remote Config - Future _loadThemesFromRemoteConfig() async { - try { - // Check if event themes are globally enabled - if (!_remoteConfigService.areEventThemesEnabled()) { - _logger.logInfo('Event themes are disabled via Remote Config'); - _availableThemes = []; - _activeEventTheme = null; - notifyListeners(); - return; - } - - // Check for debug theme override - final debugTheme = _remoteConfigService.getDebugEventTheme(); - if (debugTheme != null && debugTheme.isNotEmpty) { - _logger.logInfo('Debug theme active: $debugTheme'); - await _activateDebugTheme(debugTheme); - return; - } - - // Get event themes configuration - final themesConfig = _remoteConfigService.getEventThemesConfig(); - - // Convert to RemoteThemeConfig objects - final remoteConfigs = themesConfig - .map((json) => RemoteThemeConfig.fromJson(json)) - .toList(); - - // Create DynamicEventTheme instances - _availableThemes = remoteConfigs - .map((config) => DynamicEventTheme(config)) - .toList(); - - _logger.logInfo('Loaded ${_availableThemes.length} themes from Remote Config'); - - // Check for active event - _checkForActiveEvent(); - - } catch (e) { - _logger.logError('Error loading themes from Remote Config: $e'); - _availableThemes = []; - _activeEventTheme = null; - } - - notifyListeners(); - } - - /// Handle Remote Config updates - void _onRemoteConfigUpdated() { - _logger.logInfo('Remote Config updated - reloading themes'); - _loadThemesFromRemoteConfig(); - } - - /// Check for active events based on date - void _checkForActiveEvent() { - // Filter themes that should be active - final activeThemes = _availableThemes - .where((theme) => theme.metadata.shouldActivate) - .toList() - ..sort((a, b) => b.metadata.priority.compareTo(a.metadata.priority)); - - final previousTheme = _activeEventTheme; - _activeEventTheme = activeThemes.isNotEmpty ? activeThemes.first : null; - - // Log theme change - if (previousTheme?.metadata.id != _activeEventTheme?.metadata.id) { - if (_activeEventTheme != null) { - _logger.logInfo('Event theme activated: ${_activeEventTheme!.metadata.nameEn}'); - } else { - _logger.logInfo('Event theme deactivated'); - } - } - } - - /// Activate debug theme (for testing) - Future _activateDebugTheme(String themeType) async { - final theme = _availableThemes.firstWhere( - (t) => t.metadata.type.toString().contains(themeType), - orElse: () { - // If no matching theme found, create a default one - _logger.logWarning('Debug theme not found: $themeType'); - return _availableThemes.isNotEmpty ? _availableThemes.first : _activeEventTheme!; - }, - ); - - _activeEventTheme = theme; - _logger.logInfo('Debug theme activated: $themeType'); - notifyListeners(); - } - - /// Manual activation (for testing or admin override) - void activateTheme(EventType type) { - _activeEventTheme = _availableThemes.firstWhere( - (theme) => theme.metadata.type == type, - orElse: () => _activeEventTheme!, - ); - _logger.logInfo('Manual theme activation: ${type.toString()}'); - notifyListeners(); - } - - /// Disable event theme (revert to standard) - void deactivateEventTheme() { - _activeEventTheme = null; - _logger.logInfo('Event theme manually deactivated'); - notifyListeners(); - } - - /// Periodic check (call from app lifecycle) - void checkDaily() { - _logger.logInfo('Performing daily theme check'); - _checkForActiveEvent(); - notifyListeners(); - } - - /// Force refresh from Remote Config - Future refresh() async { - _logger.logInfo('Forcing theme refresh from Remote Config'); - await _remoteConfigService.refresh(); - await _loadThemesFromRemoteConfig(); - } - - @override - void dispose() { - _remoteConfigService.removeListener(_onRemoteConfigUpdated); - super.dispose(); - } -} -``` - ---- - -### **Phase 2: Color System Integration (Day 3)** - -#### Step 2.1: Extend AppColors Class - -**File**: `lib/theme/colors.dart` (MODIFICATIONS) - -```dart -class AppColors { - static bool isDarkMode = false; - static EventThemeManager? _themeManager; // NEW - - // NEW: Initialize theme manager - static void initializeEventThemeManager(EventThemeManager manager) { - _themeManager = manager; - } - - // NEW: Check if event theme overrides a color - static Color? _getEventColor(String colorKey) { - if (_themeManager?.hasActiveEvent ?? false) { - return _themeManager!.activeEventTheme!.getColor(colorKey, isDarkMode); - } - return null; - } - - // MODIFIED: Add event override check - static Color get primaryRedColor { - final eventColor = _getEventColor('primaryBrand'); - if (eventColor != null) return eventColor; - return isDarkMode ? dark.primaryRedColor : const Color(0xFFED1C2B); - } - - // MODIFIED: Scaffold background with event support - static Color get scaffoldBgColor { - final eventColor = _getEventColor('scaffoldBackground'); - if (eventColor != null) return eventColor; - return isDarkMode ? dark.scaffoldBgColor : const Color(0xFFF8F8F8); - } - - // NEW: Special event features - static bool get hasEventGradient => - _themeManager?.activeEventTheme?.backgroundGradient != null; - - static LinearGradient? get eventGradient => - _themeManager?.activeEventTheme?.backgroundGradient; - - static String? get eventBannerImage => - _themeManager?.activeEventTheme?.celebrationBannerImage; - - static bool get showConfetti => - _themeManager?.activeEventTheme?.showConfetti ?? false; - - // ... existing code continues -} -``` - ---- - -### **Phase 3: ViewModel Integration (Day 4)** - -#### Step 3.1: Extend ProfileSettingsViewModel - -**File**: `lib/features/profile_settings/profile_settings_view_model.dart` (MODIFICATIONS) - -```dart -class ProfileSettingsViewModel extends ChangeNotifier { - // ... existing code ... - - final EventThemeManager _eventThemeManager; // NEW - - ProfileSettingsViewModel({ - required CacheService cacheService, - required this.profileSettingsRepo, - required this.errorHandlerService, - required EventThemeManager eventThemeManager, // NEW - }) : _cacheService = cacheService, - _eventThemeManager = eventThemeManager { - // Listen to event theme changes - _eventThemeManager.addListener(_onEventThemeChanged); - } - - // NEW: Event theme state - BaseEventTheme? get activeEventTheme => _eventThemeManager.activeEventTheme; - bool get hasActiveEvent => _eventThemeManager.hasActiveEvent; - String get eventName => activeEventTheme?.metadata.nameEn ?? ''; - - // NEW: Handle event theme changes - void _onEventThemeChanged() { - notifyListeners(); // Rebuild UI when event theme changes - } - - // NEW: Manual event theme control (for admin/testing) - void activateEventTheme(EventType type) { - _eventThemeManager.activateTheme(type); - } - - void deactivateEventTheme() { - _eventThemeManager.deactivateEventTheme(); - } - - // EXISTING: Dark mode toggle (unchanged) - void toggleDarkMode(bool value) { - _isDarkMode = value; - AppColors.isDarkMode = value; - _cacheService.saveBool(key: _darkModeKey, value: value); - notifyListeners(); - } - - @override - void dispose() { - _eventThemeManager.removeListener(_onEventThemeChanged); - super.dispose(); - } -} -``` - ---- - -### **Phase 4: App Initialization (Day 5)** - -#### Step 4.1: Update Dependencies - -**File**: `lib/core/dependencies.dart` (MODIFICATIONS) - -```dart -Future addDependencies() async { - // ... existing dependencies ... - - // NEW: Register Event Theme Manager (Firebase-driven) - getIt.registerLazySingleton(() { - return EventThemeManager(); - // Themes are loaded from Firebase Remote Config - // No hardcoded themes needed! - }); - - // MODIFIED: Inject EventThemeManager into ProfileSettingsViewModel - getIt.registerLazySingleton(() => - ProfileSettingsViewModel( - cacheService: getIt(), - profileSettingsRepo: getIt(), - errorHandlerService: getIt(), - eventThemeManager: getIt(), // NEW - ) - ); -} -``` - -#### Step 4.2: Update Main App - -**File**: `lib/main.dart` (MODIFICATIONS) - -```dart -Future callInitializations() async { - // ... existing code ... - - await AppDependencies.addDependencies(); - - // NEW: Initialize event theme system with Firebase Remote Config - final eventThemeManager = getIt.get(); - await eventThemeManager.initialize(); // Fetches from Firebase - AppColors.initializeEventThemeManager(eventThemeManager); - - // Existing: Load dark mode - getIt.get().loadDarkMode(); - - // NEW: Check for active events (will use Firebase data) - eventThemeManager.checkDaily(); -} - -class MyApp extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, profileVm, _) { - final isArabic = EasyLocalization.of(context)?.locale.languageCode == "ar"; - - return MaterialApp( - // NEW: Include event theme in key to force rebuild - key: ValueKey('app_theme_${profileVm.isDarkMode}_${profileVm.hasActiveEvent}'), - theme: AppTheme.getTheme(isArabic), - darkTheme: AppTheme.getDarkTheme(isArabic), - themeMode: profileVm.isDarkMode ? ThemeMode.dark : ThemeMode.light, - // ... other properties - ); - }, - ); - } -} -``` - -#### Step 4.3: App Lifecycle Integration - -**File**: `lib/services/app_lifecycle_service.dart` (MODIFICATIONS) - -```dart -class AppLifecycleService with WidgetsBindingObserver { - final EventThemeManager _eventThemeManager; - - AppLifecycleService({ - required EventThemeManager eventThemeManager, - }) : _eventThemeManager = eventThemeManager; - - void initialize() { - WidgetsBinding.instance.addObserver(this); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - - // Refresh Remote Config when app comes to foreground - if (state == AppLifecycleState.resumed) { - _eventThemeManager.refresh(); - } - } - - void dispose() { - WidgetsBinding.instance.removeObserver(this); - } -} -``` - ---- - -### **Phase 5: UI Components & Firebase Console Setup (Day 5-6)** - -#### Step 5.1: Event Banner Widget (Optional) - -**File**: `lib/widgets/event_banner.dart` (NEW) - -```dart -class EventBanner extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, vm, _) { - if (!vm.hasActiveEvent) return SizedBox.shrink(); - - final theme = vm.activeEventTheme!; - final isArabic = context.locale.languageCode == 'ar'; - - return Container( - padding: EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: theme.backgroundGradient, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - if (theme.celebrationBannerImage != null) - Image.asset(theme.celebrationBannerImage!, height: 40), - SizedBox(width: 12), - Text( - isArabic ? theme.metadata.nameAr : theme.metadata.nameEn, - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - }, - ); - } -} -``` - -#### Step 5.2: Confetti Effect (Optional) - -**File**: `lib/widgets/confetti_overlay.dart` (NEW) - -```dart -class ConfettiOverlay extends StatefulWidget { - final Widget child; - - const ConfettiOverlay({required this.child}); - - @override - State createState() => _ConfettiOverlayState(); -} - -class _ConfettiOverlayState extends State { - late ConfettiController _controller; - - @override - void initState() { - super.initState(); - _controller = ConfettiController(duration: Duration(seconds: 3)); - - if (AppColors.showConfetti) { - _controller.play(); - } - } - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - widget.child, - if (AppColors.showConfetti) - Align( - alignment: Alignment.topCenter, - child: ConfettiWidget( - confettiController: _controller, - // ... confetti configuration - ), - ), - ], - ); - } -} -``` - ---- - -## 🔄 Migration Strategy - -### For Existing Screens -**No changes required!** All existing code continues to work: - -```dart -// Existing code - automatically gets event colors -Container( - color: AppColors.primaryRedColor, // Will be green on National Day -) -``` - -### For New Features -Use event-aware colors: - -```dart -// Check if event is active -if (context.read().hasActiveEvent) { - // Show event-specific UI -} - -// Use event banner -EventBanner(), - -// Use event gradient -Container( - decoration: BoxDecoration( - gradient: AppColors.eventGradient ?? defaultGradient, - ), -) -``` - ---- - -## 🧪 Testing Strategy - -### Firebase Remote Config Testing - -#### Unit Tests -```dart -// Test Remote Config parsing -test('Should parse National Day theme from Remote Config JSON', () { - final json = { - 'id': 'saudi_national_day_2026', - 'type': 'nationalDay', - 'primaryColor': '#006C35', - // ... more fields - }; - - final config = RemoteThemeConfig.fromJson(json); - expect(config.id, 'saudi_national_day_2026'); - expect(config.parseColor('#006C35'), Color(0xFF006C35)); -}); - -// Test date-based activation -test('Should activate theme when current date is in range', () async { - final manager = EventThemeManager(); - await manager.initialize(); - - // Mock Remote Config to return active theme - expect(manager.hasActiveEvent, true); -}); - -// Test priority system -test('Higher priority theme should win when multiple active', () async { - final manager = EventThemeManager(); - // Remote Config returns: Eid (priority 15) + National Day (priority 10) - await manager.initialize(); - - expect(manager.activeEventTheme?.metadata.priority, 15); -}); - -// Test color override -test('Event color should override base color', () { - AppColors.initializeEventThemeManager(mockManager); - - final color = AppColors.primaryRedColor; - expect(color, isNot(Color(0xFFED1C2B))); // Not default red - expect(color, Color(0xFF006C35)); // National Day green -}); -``` - -#### Integration Tests -```dart -testWidgets('Theme should update when Remote Config changes', (tester) async { - await tester.pumpWidget(MyApp()); - - // Initial state - no event theme - expect(find.byType(EventBanner), findsNothing); - - // Simulate Remote Config update - await mockRemoteConfig.setTheme(nationalDayTheme); - await tester.pumpAndSettle(); - - // Event banner should appear - expect(find.byType(EventBanner), findsOneWidget); - expect(find.text('Saudi National Day'), findsOneWidget); -}); - -testWidgets('Dark mode should work with event theme', (tester) async { - await tester.pumpWidget(MyApp()); - - // Activate National Day theme - final profileVm = tester.read(); - profileVm.toggleDarkMode(true); - await tester.pumpAndSettle(); - - // Should use dark variant of event color - final container = tester.widget(find.byType(Container).first); - expect(container.color, Color(0xFF008A45)); // Dark green, not light green -}); -``` - -### Manual Testing Checklist - -#### Basic Functionality -- [ ] Remote Config fetches successfully on app launch -- [ ] Theme activates when date range matches -- [ ] Theme deactivates when date range ends -- [ ] Multiple themes respect priority system -- [ ] Debug mode forces specific theme -- [ ] Global disable switch works (`enable_event_themes: false`) - -#### Color System -- [ ] Event colors override base colors correctly -- [ ] Dark mode variants display correctly -- [ ] Gradient displays when configured -- [ ] Invalid hex colors don't crash app -- [ ] Missing colors fallback to base theme - -#### Dark/Light Mode Integration -- [ ] Toggle dark mode with event active -- [ ] Toggle dark mode with event inactive -- [ ] Event colors adapt to dark mode -- [ ] Existing screens work in both modes - -#### UI Components -- [ ] Event banner displays in Arabic/English -- [ ] Banner image loads from URL -- [ ] Confetti plays when enabled -- [ ] Animations work smoothly -- [ ] Banner hides when event inactive - -#### Firebase Console -- [ ] JSON validation works in console -- [ ] Publish changes propagate to app -- [ ] Conditional targeting works (region, language) -- [ ] A/B testing configuration works -- [ ] Rollback to previous config works - -#### Performance -- [ ] Remote Config fetch doesn't block UI -- [ ] Theme changes are smooth (no jank) -- [ ] Color resolution is fast -- [ ] App startup time unchanged -- [ ] Memory usage normal - -#### Edge Cases -- [ ] Invalid JSON doesn't crash app -- [ ] Network failure doesn't break app -- [ ] Empty theme array works -- [ ] Overlapping date ranges handled -- [ ] Past event dates ignored -- [ ] Future event dates wait - ---- - -## 📊 Performance Considerations - -### Optimization 1: Remote Config Caching -```dart -class RemoteConfigService { - // Firebase SDK automatically caches for minimumFetchInterval - // Default: 1 hour (production), 0 seconds (debug) - - Future initialize() async { - await _remoteConfig.setConfigSettings(RemoteConfigSettings( - fetchTimeout: const Duration(minutes: 1), - minimumFetchInterval: const Duration(hours: 1), // Cache for 1 hour - )); - } -} -``` - -### Optimization 2: Color Caching -```dart -class DynamicEventTheme { - final Map _colorCache = {}; - - @override - Color? getColor(String colorKey, bool isDarkMode) { - final cacheKey = '${colorKey}_$isDarkMode'; - - if (_colorCache.containsKey(cacheKey)) { - return _colorCache[cacheKey]; - } - - final color = _computeColor(colorKey, isDarkMode); - _colorCache[cacheKey] = color; - return color; - } -} -``` - -### Optimization 3: Lazy Theme Loading -```dart -class EventThemeManager { - // Themes are only created when Remote Config has data - // No hardcoded themes = smaller app bundle - - Future _loadThemesFromRemoteConfig() async { - // Only parse JSON when needed - final themesConfig = _remoteConfigService.getEventThemesConfig(); - - _availableThemes = themesConfig - .map((json) => DynamicEventTheme(RemoteThemeConfig.fromJson(json))) - .toList(); - } -} -``` - -### Optimization 4: App Lifecycle Optimization -```dart -class AppLifecycleService { - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - // Only refresh when app comes to foreground - // Not on every frame - _eventThemeManager.refresh(); - } - } -} -``` - ---- - -## 🔥 Firebase Remote Config Setup Guide - -### Step 1: Firebase Console Configuration - -#### 1.1 Create Remote Config Parameters - -Navigate to Firebase Console → Remote Config → Add parameter - -**Parameter 1: `event_themes_config`** -- **Type**: JSON -- **Description**: Array of event theme configurations -- **Default Value**: `[]` (empty array) - -**Parameter 2: `enable_event_themes`** -- **Type**: Boolean -- **Description**: Global on/off switch for event themes -- **Default Value**: `true` - -**Parameter 3: `debug_event_theme`** -- **Type**: String -- **Description**: Force specific theme for testing (e.g., "nationalDay") -- **Default Value**: `""` (empty string) - ---- - -### Step 2: Event Theme Configuration Examples - -#### Example 1: Saudi National Day Theme - -```json -[ - { - "id": "saudi_national_day_2026", - "type": "nationalDay", - "nameEn": "Saudi National Day", - "nameAr": "اليوم الوطني السعودي", - "startDate": "2026-09-20T00:00:00Z", - "endDate": "2026-09-25T23:59:59Z", - "isActive": true, - "priority": 10, - "primaryColor": "#006C35", - "primaryColorDark": "#008A45", - "secondaryColor": "#FFFFFF", - "secondaryColorDark": "#E8E8E8", - "accentColor": "#FFD700", - "accentColorDark": "#FFC700", - "scaffoldBgColor": "#F5FFF5", - "scaffoldBgColorDark": "#0A2617", - "cardBgColor": "#FFFFFF", - "cardBgColorDark": "#1E3A24", - "appBarColor": "#006C35", - "appBarColorDark": "#008A45", - "gradientColors": ["#006C35", "#008A45", "#00A651"], - "gradientStart": "topLeft", - "gradientEnd": "bottomRight", - "bannerImageUrl": "https://yourdomain.com/images/national_day_banner.png", - "showConfetti": true, - "enableAnimations": true - } -] -``` - -#### Example 2: Ramadan Theme - -```json -[ - { - "id": "ramadan_2026", - "type": "ramadan", - "nameEn": "Ramadan Kareem", - "nameAr": "رمضان كريم", - "startDate": "2026-02-18T00:00:00Z", - "endDate": "2026-03-20T23:59:59Z", - "isActive": true, - "priority": 12, - "primaryColor": "#1C3664", - "primaryColorDark": "#2A4A7F", - "secondaryColor": "#FFD700", - "secondaryColorDark": "#FFC700", - "accentColor": "#F5C842", - "accentColorDark": "#E8B923", - "scaffoldBgColor": "#F0F4FF", - "scaffoldBgColorDark": "#0D1929", - "gradientColors": ["#1C3664", "#2A4A7F", "#3D5FA8"], - "gradientStart": "topCenter", - "gradientEnd": "bottomCenter", - "bannerImageUrl": "https://yourdomain.com/images/ramadan_banner.png", - "showConfetti": false, - "enableAnimations": true - } -] -``` - -#### Example 3: Multiple Active Events (Priority System) - -```json -[ - { - "id": "eid_al_fitr_2026", - "type": "eid", - "nameEn": "Eid Al-Fitr", - "nameAr": "عيد الفطر", - "startDate": "2026-03-21T00:00:00Z", - "endDate": "2026-03-24T23:59:59Z", - "isActive": true, - "priority": 15, - "primaryColor": "#6B4FA0", - "primaryColorDark": "#8A6BB8", - "secondaryColor": "#FFD700", - "accentColor": "#E8B923", - "showConfetti": true, - "enableAnimations": true - }, - { - "id": "saudi_founding_day_2026", - "type": "foundingDay", - "nameEn": "Saudi Founding Day", - "nameAr": "يوم التأسيس السعودي", - "startDate": "2026-02-20T00:00:00Z", - "endDate": "2026-02-24T23:59:59Z", - "isActive": true, - "priority": 8, - "primaryColor": "#8B4513", - "primaryColorDark": "#A0522D", - "accentColor": "#D2691E", - "showConfetti": false - }, - { - "id": "new_year_2027", - "type": "newYear", - "nameEn": "Happy New Year 2027", - "nameAr": "سنة جديدة سعيدة ٢٠٢٧", - "startDate": "2026-12-30T00:00:00Z", - "endDate": "2027-01-02T23:59:59Z", - "isActive": true, - "priority": 5, - "primaryColor": "#0F4C81", - "accentColor": "#C0C0C0", - "showConfetti": true - } -] -``` - ---- - -### Step 3: Testing Configurations - -#### Debug Mode Configuration - -To test a specific theme without waiting for dates: - -**Set `debug_event_theme` to:** -- `"nationalDay"` - Force National Day theme -- `"ramadan"` - Force Ramadan theme -- `"eid"` - Force Eid theme -- `""` (empty) - Disable debug mode - -#### Disable All Event Themes - -**Set `enable_event_themes` to:** `false` - -This will instantly disable all event themes across all users. - ---- - -### Step 4: Conditional Targeting (Advanced) - -Firebase Remote Config supports conditional targeting: - -#### Example: Saudi Arabia Only -``` -Condition: Country/Region == SA -``` - -#### Example: Arabic Language Users Only -``` -Condition: Languages includes ar -``` - -#### Example: Beta Users -``` -Condition: User in audience "beta_testers" -``` - -#### Example: A/B Testing -``` -Condition A (50%): Show National Day theme with confetti -Condition B (50%): Show National Day theme without confetti -``` - ---- - -### Step 5: Rollout Strategy - -#### Gradual Rollout -1. **Day 1**: 10% of users -2. **Day 2**: 25% of users -3. **Day 3**: 50% of users -4. **Day 4**: 100% of users - -#### Instant Activation -- Set date range to current date -- Publish configuration -- Users will see theme on next app foreground (auto-refresh) - -#### Emergency Rollback -- Set `enable_event_themes` to `false` -- OR set theme `isActive` to `false` -- Publish immediately -- Users revert to standard theme within 1 hour (or next app launch) - ---- - -### Step 6: Color Palette Reference - -#### Saudi National Day Colors -``` -Primary Green: #006C35 (Light) / #008A45 (Dark) -Saudi White: #FFFFFF (Light) / #E8E8E8 (Dark) -Gold Accent: #FFD700 -Background: #F5FFF5 (Light) / #0A2617 (Dark) -``` - -#### Ramadan Colors -``` -Night Blue: #1C3664 (Light) / #2A4A7F (Dark) -Crescent Gold: #F5C842 (Light) / #E8B923 (Dark) -Background: #F0F4FF (Light) / #0D1929 (Dark) -``` - -#### Eid Colors -``` -Islamic Purple: #6B4FA0 (Light) / #8A6BB8 (Dark) -Festive Gold: #FFD700 -Light Gold: #E8B923 -``` - -#### New Year Colors -``` -Midnight Blue: #0F4C81 -Silver: #C0C0C0 -Champagne Gold: #F7E7CE -``` - ---- - -### Step 7: Best Practices - -#### ✅ Do's -- Always provide both light and dark color variants -- Use ISO 8601 format for dates: `"2026-09-20T00:00:00Z"` -- Test theme in both English and Arabic -- Start events 1-2 days before actual date for early celebration -- End events 1 day after to allow for celebrations -- Use priority system: Higher numbers = higher priority -- Test with `debug_event_theme` before production release - -#### ❌ Don'ts -- Don't overlap same-priority events (use priority system) -- Don't use invalid hex colors (use # prefix) -- Don't set very long date ranges (affects performance) -- Don't forget to set `isActive: true` -- Don't use gradients with less than 2 colors -- Don't set all themes to priority 999 (defeats purpose) - ---- - -### Step 8: Monitoring & Analytics - -Track these metrics in Firebase Analytics: - -```dart -// Log when theme is activated -GALogger('event_theme_activated', parameters: { - 'theme_id': 'saudi_national_day_2026', - 'theme_type': 'nationalDay', - 'is_dark_mode': isDarkMode, - 'priority': 10, -}); - -// Log user engagement -GALogger('event_theme_viewed', parameters: { - 'theme_id': themeId, - 'session_duration': sessionDuration, - 'confetti_shown': showConfetti, -}); - -// Log errors -GALogger('event_theme_error', parameters: { - 'error_type': 'invalid_color', - 'theme_id': themeId, - 'details': errorDetails, -}); -``` - ---- - -### Step 9: Quick Reference - Firebase Console Steps - -1. **Open Firebase Console** → Your Project -2. **Navigate** to Remote Config (left sidebar) -3. **Click** "Add parameter" -4. **Enter** parameter name: `event_themes_config` -5. **Select** type: JSON -6. **Paste** theme configuration JSON -7. **Click** "Publish changes" -8. **Wait** ~1-5 minutes for propagation -9. **Test** app (pull down to refresh OR restart app) -10. **Verify** theme is active in your app - ---- - -### Step 10: Troubleshooting - -| Issue | Solution | -|-------|----------| -| Theme not appearing | Check date ranges, ensure `isActive: true` | -| Wrong theme active | Check priority values (higher = wins) | -| Colors not changing | Verify hex format (#RRGGBB), check dark mode | -| App crashes | Validate JSON syntax in Firebase Console | -| Gradient not showing | Ensure 2+ colors, valid alignment values | -| Confetti not working | Check `showConfetti: true` in config | -| Debug theme stuck | Set `debug_event_theme` to empty string `""` | -| Remote Config not fetching | Check network, Firebase SDK version | - ---- - -## 🌐 Remote Configuration (Optional - Phase 7) - -### Firebase Remote Config Integration - -**File**: `lib/core/remote_config_service.dart` (NEW) - -```dart -class RemoteConfigService { - final FirebaseRemoteConfig _remoteConfig = FirebaseRemoteConfig.instance; - - Future initialize() async { - await _remoteConfig.setConfigSettings(RemoteConfigSettings( - fetchTimeout: Duration(minutes: 1), - minimumFetchInterval: Duration(hours: 1), - )); - - await _remoteConfig.setDefaults({ - 'active_event_themes': '[]', - 'enable_confetti': false, - }); - - await _remoteConfig.fetchAndActivate(); - } - - List> getActiveEventThemes() { - final json = _remoteConfig.getString('active_event_themes'); - return jsonDecode(json); - } - - bool isConfettiEnabled() { - return _remoteConfig.getBool('enable_confetti'); - } -} -``` - -### Remote Theme Activation -```json -{ - "active_event_themes": [ - { - "type": "nationalDay", - "startDate": "2026-09-20T00:00:00Z", - "endDate": "2026-09-24T23:59:59Z", - "isActive": true, - "priority": 10 - } - ], - "enable_confetti": true -} -``` - ---- - -## 📅 Implementation Timeline - -| Phase | Duration | Deliverables | -|-------|----------|--------------| -| **Phase 1** | 2 days | Base infrastructure, models, manager | -| **Phase 2** | 1 day | Concrete event themes (4 themes) | -| **Phase 3** | 1 day | Color system integration | -| **Phase 4** | 1 day | ViewModel integration | -| **Phase 5** | 1 day | App initialization, DI setup | -| **Phase 6** | 1 day | UI components (banner, confetti) | -| **Phase 7** | 1 day | Remote config (optional) | -| **Testing** | 2 days | Unit tests, manual testing | -| **Total** | **10 days** | Full implementation + testing | - ---- - -## ✅ Benefits Summary - -### 1. **Minimal Code Changes** -- Zero breaking changes to existing screens -- Only 4 files modified, 8 files added -- Existing dark/light mode logic preserved - -### 2. **Scalability** -- Add new event themes by creating single class -- Unlimited simultaneous events (priority-based) -- Easy to extend with new color overrides - -### 3. **Flexibility** -- Auto-activation based on dates -- Manual override for testing/admin -- Remote configuration support -- Per-event dark mode variants - -### 4. **Maintainability** -- Clear separation of concerns -- Type-safe theme definitions -- Centralized event management -- Easy debugging with event state - -### 5. **User Experience** -- Seamless theme transitions -- Festive UI without disruption -- Preserves user's dark mode preference -- Optional celebration effects (confetti, banners) - ---- - -## 🚀 Quick Start After Approval - -1. Create base infrastructure (Phase 1) -2. Implement 1 event theme as POC (National Day) -3. Test with dark mode toggle -4. Get stakeholder approval -5. Implement remaining themes -6. Add UI enhancements -7. Deploy with remote config - ---- - -## 📝 Notes & Considerations - -### A. Hijri Calendar Support -For Islamic events (Ramadan, Eid), integrate Hijri calendar: -```dart -// Use package: hijri -import 'package:hijri/hijri.dart'; - -DateTime getRamadanStartDate(int gregorianYear) { - // Convert Hijri date to Gregorian - final hijri = HijriCalendar.fromDate(DateTime(gregorianYear, 1, 1)); - // Find Ramadan (month 9) - // ... -} -``` - -### B. Asset Management -Store event-specific assets: -``` -assets/ -└── images/ - └── events/ - ├── national_day_banner.png - ├── eid_banner.png - ├── ramadan_crescent.png - └── new_year_fireworks.png -``` - -### C. Analytics Integration -Track event theme engagement: -```dart -GALogger('event_theme_viewed', parameters: { - 'theme_type': 'national_day', - 'is_dark_mode': isDarkMode, -}); -``` - ---- - -## 🎯 Success Criteria - -- [ ] National Day theme activates automatically Sept 20-24 -- [ ] Theme works in both light and dark modes -- [ ] Existing screens show event colors without modification -- [ ] User can toggle dark mode while event is active -- [ ] Multiple events respect priority system -- [ ] Theme deactivates automatically after event -- [ ] Event banner displays correctly in Arabic/English -- [ ] Performance impact < 5ms per frame -- [ ] Remote config can enable/disable themes -- [ ] Zero crashes related to theme switching - ---- - -**Created**: May 15, 2026 -**Author**: AI Architecture Team -**Status**: Ready for Implementation -**Version**: 1.0 - diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index b69827d4..96908b62 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -468,6 +468,7 @@ "serviceInformation": "معلومات الخدمة", "homeHealthCare": "الرعاية الصحية المنزلية", "noAppointmentAvailable": "لا توجد مواعيد متاحة", + "noScheduleAvailable": "لا يوجد جدول متاح حاليًا", "loginRegister": "تسجيل الدخول / التسجيل", "orderLog": "سجل الطلب", "infoLab": "تتيح لك هذه الخدمة عرض نتائج جميع الفحوصات المخبرية التي أجريت في مجموعة الحبيب الطبية بالإضافة إلى إرسال التقرير عبر البريد الإلكتروني.", @@ -1959,7 +1960,11 @@ "refund": "استرداد", "parkingQR": "رمز الاستجابة السريعة لوقوف السيارات", "parkingQRDesc": "امسح رمز الاستجابة السريعة (QR Code) أدناه عند بوابة الخروج من موقف السيارات:", - "livecareUpdateInsuranceSent": "تم استلام طلب تحديث بيانات التأمين الخاص بك، ويجري العمل عليه حالياً. سنقوم بإشعارك فور اكتمال التحديث.", - "livecareUpdateInsurance": "سيتم تحويل طلب تحديث بيانات التأمين إلى فريق LiveCare المختص لاستكمال الإجراءات. هل تود المتابعة؟", - "schedule": "جدول" + "livecareUpdateInsuranceSent": "تم استلام طلب تحديث بيانات التأمين الخاص بك، ويجري العمل عليه حالياً. سنقوم بإشعارك فور اكتمال التحديث.", + "livecareUpdateInsurance": "سيتم تحويل طلب تحديث بيانات التأمين إلى فريق LiveCare المختص لاستكمال الإجراءات. هل تود المتابعة؟", + "schedule": "جدول", + "doctorSchedule": "جدول الطبيب", + "weeklyShift": "الفترات الأسبوعية", + "dayOff": "إجازة", + "notAvailable": "غير متاح" } diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index bb5b5fe4..0a34c8bf 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -460,6 +460,7 @@ "serviceInformation": "Service Information", "homeHealthCare": "Home Health Care", "noAppointmentAvailable": "No Available Appointments", + "noScheduleAvailable": "No schedule currently available", "homeHealthCareText": "Clinical excellence and specialized care in the comfort of your home", "loginRegister": "Login/Register", "orderLog": "Order Log", @@ -1948,9 +1949,13 @@ "invoiceHistory": "Invoice History", "parkingQR": "Parking QR Code", "parkingQRDesc": "Scan the below QR Code at the exit parking gate:", - "livecareUpdateInsuranceSent": "Your insurance update request is being processed. You will be notified once the update is completed.", - "livecareUpdateInsurance": "Please note that your insurance update request will be sent to the our LiveCare team for update. Would you like to proceed?", - "schedule": "Schedule" + "livecareUpdateInsuranceSent": "Your insurance update request is being processed. You will be notified once the update is completed.", + "livecareUpdateInsurance": "Please note that your insurance update request will be sent to the our LiveCare team for update. Would you like to proceed?", + "schedule": "Schedule", + "doctorSchedule": "Doctor Schedule", + "weeklyShift": "Weekly Shift", + "dayOff": "Off", + "notAvailable": "Not Available" } diff --git a/lib/features/book_appointments/book_appointments_repo.dart b/lib/features/book_appointments/book_appointments_repo.dart index 9afa113e..e5200f67 100644 --- a/lib/features/book_appointments/book_appointments_repo.dart +++ b/lib/features/book_appointments/book_appointments_repo.dart @@ -113,6 +113,8 @@ abstract class BookAppointmentsRepo { Future>> insertFavouriteDoctor( {required int patientID, required int projectID, required int clinicID, required int doctorID, required bool isActive, Function(dynamic)? onSuccess, Function(String)? onError}); + + Future>> getDoctorSchedule({required int doctorID, required int projectID, required int clinicID, int doctorWorkingHoursDays = 90, Function(dynamic)? onSuccess, Function(String)? onError}); } class BookAppointmentsRepoImp implements BookAppointmentsRepo { @@ -1228,4 +1230,49 @@ class BookAppointmentsRepoImp implements BookAppointmentsRepo { return Left(UnknownFailure(e.toString())); } } + + @override + Future>> getDoctorSchedule({required int doctorID, required int projectID, required int clinicID, int doctorWorkingHoursDays = 90, Function(dynamic)? onSuccess, Function(String)? onError}) async { + Map mapRequest = { + "DoctorID": doctorID, + "ProjectID": projectID, + "ClinicID": clinicID, + "DoctorWorkingHoursDays": doctorWorkingHoursDays, + }; + + try { + GenericApiModel? apiResponse; + Failure? failure; + await apiClient.post( + DOCTOR_SCHEDULE_URL, + body: mapRequest, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + if (onError != null) { + onError(error); + } + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + apiResponse = GenericApiModel( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: response, + ); + if (onSuccess != null) { + onSuccess(response); + } + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, + ); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } } diff --git a/lib/features/book_appointments/book_appointments_view_model.dart b/lib/features/book_appointments/book_appointments_view_model.dart index e34e2188..e4f028d9 100644 --- a/lib/features/book_appointments/book_appointments_view_model.dart +++ b/lib/features/book_appointments/book_appointments_view_model.dart @@ -1816,4 +1816,48 @@ class BookAppointmentsViewModel extends ChangeNotifier { }, ); } + + bool isDoctorScheduleLoading = false; + List doctorFreeSlotDates = []; + dynamic doctorScheduleResponse; + + Future getDoctorSchedule({required int doctorID, required int projectID, required int clinicID, int doctorWorkingHoursDays = 90, Function(dynamic)? onSuccess, Function(String)? onError}) async { + isDoctorScheduleLoading = true; + notifyListeners(); + + final result = await bookAppointmentsRepo.getDoctorSchedule( + doctorID: doctorID, + projectID: projectID, + clinicID: clinicID, + doctorWorkingHoursDays: doctorWorkingHoursDays, + ); + + result.fold( + (failure) async { + isDoctorScheduleLoading = false; + notifyListeners(); + if (onError != null) { + onError(failure.message); + } + }, + (apiResponse) { + isDoctorScheduleLoading = false; + if (apiResponse.messageStatus == 2) { + notifyListeners(); + if (onError != null) { + onError(apiResponse.errorMessage ?? "Failed to fetch doctor schedule"); + } + } else if (apiResponse.messageStatus == 1) { + doctorScheduleResponse = apiResponse.data; + if (doctorScheduleResponse != null && doctorScheduleResponse['FreeTimeSlots'] != null) { + doctorFreeSlotDates = List.from(doctorScheduleResponse['FreeTimeSlots']); + } + notifyListeners(); + if (onSuccess != null) { + onSuccess(apiResponse.data); + } + } + }, + ); + } } diff --git a/lib/features/book_appointments/models/resp_models/doctor_schedule_response_model.dart b/lib/features/book_appointments/models/resp_models/doctor_schedule_response_model.dart new file mode 100644 index 00000000..2553b0ae --- /dev/null +++ b/lib/features/book_appointments/models/resp_models/doctor_schedule_response_model.dart @@ -0,0 +1,26 @@ +class DoctorScheduleResponseModel { + String? date; + String? dayName; + String? workingHours; + + DoctorScheduleResponseModel({ + this.date, + this.dayName, + this.workingHours, + }); + + DoctorScheduleResponseModel.fromJson(Map json) { + date = json['Date']; + dayName = json['DayName']; + workingHours = json['WorkingHours']; + } + + Map toJson() { + final Map data = {}; + data['Date'] = date; + data['DayName'] = dayName; + data['WorkingHours'] = workingHours; + return data; + } +} + diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index c82e2d12..9d48db25 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -469,6 +469,7 @@ abstract class LocaleKeys { static const serviceInformation = 'serviceInformation'; static const homeHealthCare = 'homeHealthCare'; static const noAppointmentAvailable = 'noAppointmentAvailable'; + static const noScheduleAvailable = 'noScheduleAvailable'; static const loginRegister = 'loginRegister'; static const orderLog = 'orderLog'; static const infoLab = 'infoLab'; @@ -1943,5 +1944,8 @@ abstract class LocaleKeys { static const livecareUpdateInsuranceSent = 'livecareUpdateInsuranceSent'; static const livecareUpdateInsurance = 'livecareUpdateInsurance'; static const schedule = 'schedule'; + static const doctorSchedule = 'doctorSchedule'; + static const weeklyShift = 'weeklyShift'; + static const dayOff = 'dayOff'; } diff --git a/lib/presentation/book_appointment/doctor_profile_page.dart b/lib/presentation/book_appointment/doctor_profile_page.dart index f54f95bf..6be110ec 100644 --- a/lib/presentation/book_appointment/doctor_profile_page.dart +++ b/lib/presentation/book_appointment/doctor_profile_page.dart @@ -12,6 +12,8 @@ import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_vie import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/appointment_calendar.dart'; import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/doctor_rating_details.dart'; +import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/doctor_schedule_page.dart'; +import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.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'; @@ -200,7 +202,9 @@ class DoctorProfilePage extends StatelessWidget { LocaleKeys.schedule.tr(context: context).toText12(isBold: true, color: AppColors.greyTextColor), LocaleKeys.view.tr(context: context).toText16(isBold: true, color: AppColors.textColor, isUnderLine: true, decorationColor: AppColors.textColor, fontFamily: "Poppins"), ], - ).onPress(() {}) + ).onPress(() { + _openDoctorSchedule(context); + }) ], ), SizedBox(height: 16.h), @@ -303,4 +307,75 @@ class DoctorProfilePage extends StatelessWidget { ), ); } + + // Guard against double-tap opening two bottom sheets simultaneously + static bool _scheduleInFlight = false; + + void _openDoctorSchedule(BuildContext context) async { + if (_scheduleInFlight) return; + _scheduleInFlight = true; + + final bookAppointmentsViewModel = Provider.of(context, listen: false); + + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.fetchingDoctorSchedulePleaseWait.tr(context: context)); + + // Yield to the frame scheduler so the loader is fully painted + // before the API calls start (prevents the "loader appears late" glitch). + await Future.delayed(Duration.zero); + + if (bookAppointmentsViewModel.freeSlotsResponse == null || bookAppointmentsViewModel.freeSlotsResponse.isEmpty) { + await bookAppointmentsViewModel.getDoctorFreeSlots( + isBookingForLiveCare: false, + onSuccess: (dynamic respData) async { + await _fetchDoctorSchedule(context, bookAppointmentsViewModel); + }, + onError: (err) { + _scheduleInFlight = false; + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: true, + isCloseButtonVisible: true, + ); + }, + ); + } else { + await _fetchDoctorSchedule(context, bookAppointmentsViewModel); + } + } + + Future _fetchDoctorSchedule(BuildContext context, BookAppointmentsViewModel bookAppointmentsViewModel) async { + await bookAppointmentsViewModel.getDoctorSchedule( + doctorID: bookAppointmentsViewModel.selectedDoctor.doctorID!, + projectID: bookAppointmentsViewModel.selectedDoctor.projectID!, + clinicID: bookAppointmentsViewModel.selectedDoctor.clinicID!, + doctorWorkingHoursDays: 90, + onSuccess: (dynamic respData) async { + _scheduleInFlight = false; + LoaderBottomSheet.hideLoader(); + if (context.mounted) { + await Navigator.of(context).push( + CustomPageRoute( + page: const DoctorSchedulePage(), + fullScreenDialog: true, + direction: AxisDirection.down, + ), + ); + } + }, + onError: (err) { + _scheduleInFlight = false; + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }, + ); + } } diff --git a/lib/presentation/book_appointment/widgets/doctor_schedule_bottom_sheet.dart b/lib/presentation/book_appointment/widgets/doctor_schedule_bottom_sheet.dart new file mode 100644 index 00000000..78f6a22a --- /dev/null +++ b/lib/presentation/book_appointment/widgets/doctor_schedule_bottom_sheet.dart @@ -0,0 +1,400 @@ +import 'dart:ui' as ui; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/app_state.dart'; +import 'package:hmg_patient_app_new/core/dependencies.dart'; +import 'package:hmg_patient_app_new/core/utils/date_util.dart'; +import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; +import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_view_model.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/doctor_schedule_response_model.dart'; +import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; +import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/services/navigation_service.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:provider/provider.dart'; + +class DoctorScheduleBottomSheet extends StatefulWidget { + const DoctorScheduleBottomSheet({super.key}); + + @override + State createState() => _DoctorScheduleBottomSheetState(); +} + +class _DoctorScheduleBottomSheetState extends State + with SingleTickerProviderStateMixin { + late PageController _pageController; + late AnimationController _fadeController; + late Animation _fadeAnimation; + + double currentPage = 0; + List>> weeks = []; + List doctorScheduleResponse = []; + List freeSlots = []; + + /// True while weeks are being computed off the build thread + bool _isInitializing = false; + + late BookAppointmentsViewModel bookAppointmentsViewModel; + late AppState appState; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + _fadeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + _fadeAnimation = CurvedAnimation(parent: _fadeController, curve: Curves.easeIn); + } + + @override + void dispose() { + _pageController.dispose(); + _fadeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + bookAppointmentsViewModel = Provider.of(context, listen: true); + appState = getIt.get(); + final isArabic = appState.isArabic(); + + // Trigger initialization after the first frame so the UI never freezes + final responseReady = bookAppointmentsViewModel.doctorScheduleResponse != null; + if (responseReady && weeks.isEmpty && !_isInitializing) { + WidgetsBinding.instance.addPostFrameCallback((_) => _triggerInit()); + } + + // ── API loading ────────────────────────────────────────────────────────── + if (bookAppointmentsViewModel.isDoctorScheduleLoading || _isInitializing) { + return Center( + child: CircularProgressIndicator( + color: AppColors.primaryRedColor, + strokeWidth: 2.5, + ), + ); + } + + // ── No data ────────────────────────────────────────────────────────────── + if (weeks.isEmpty) { + return Center( + child: Utils.getNoDataWidget( + context, + noDataText: LocaleKeys.noScheduleAvailable.tr(context: context), + ), + ); + } + + // ── Schedule ready — fade in ───────────────────────────────────────────── + return FadeTransition( + opacity: _fadeAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context, isArabic), + SizedBox(height: 20.h), + Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: weeks.length, + onPageChanged: (index) => setState(() => currentPage = index.toDouble()), + itemBuilder: (context, weekIndex) => _buildWeekView(weekIndex, isArabic), + ), + ), + ], + ), + ); + } + + // ── Initialization ───────────────────────────────────────────────────────── + + void _triggerInit() { + if (!mounted || _isInitializing || weeks.isNotEmpty) return; + setState(() => _isInitializing = true); + + // Defer heavy work to the next microtask so the spinner is painted first + Future.microtask(() { + _buildWeeksFromResponse(); + if (mounted) { + setState(() => _isInitializing = false); + _fadeController.forward(); + } + }); + } + + void _buildWeeksFromResponse() { + final response = bookAppointmentsViewModel.doctorScheduleResponse; + if (response == null) return; + + final scheduleList = response['List_DoctorWorkingHoursTable'] as List?; + if (scheduleList == null || scheduleList.isEmpty) return; + + doctorScheduleResponse = + scheduleList.map((item) => DoctorScheduleResponseModel.fromJson(item)).toList(); + + // Resolve free-slot dates + if (bookAppointmentsViewModel.doctorFreeSlotDates.isNotEmpty) { + freeSlots = bookAppointmentsViewModel.doctorFreeSlotDates; + } else if (bookAppointmentsViewModel.freeSlotsResponse != null && + bookAppointmentsViewModel.freeSlotsResponse.isNotEmpty) { + freeSlots = List.from(bookAppointmentsViewModel.freeSlotsResponse); + } else if (response['FreeTimeSlots'] != null) { + freeSlots = List.from(response['FreeTimeSlots']); + } + + _generateWeeksSchedules(); + } + + /// Splits ALL API days into 7-day chunks — one chunk per page. + /// Supports full 3-month schedules (~13 weeks) returned by the API. + void _generateWeeksSchedules() { + weeks.clear(); + + // Convert every API day into a flat map and mark free slots + final allDays = doctorScheduleResponse.map((workDay) { + return { + 'Date': workDay.date ?? '', + 'DayName': workDay.dayName ?? '', + 'WorkingHours': workDay.workingHours ?? '', + 'fullDay': !_hasFreeSlot(workDay.date ?? ''), + }; + }).toList(); + + // Slice into pages of 7 days each + const daysPerPage = 7; + for (var i = 0; i < allDays.length; i += daysPerPage) { + final end = (i + daysPerPage).clamp(0, allDays.length); + weeks.add(allDays.sublist(i, end)); + } + } + + // ── Header ───────────────────────────────────────────────────────────────── + + Widget _buildHeader(BuildContext context, bool isArabic) { + final currentWeek = weeks[currentPage.round()]; + final firstDate = DateUtil.convertStringToDate(currentWeek.first['Date'] as String); + final lastDate = DateUtil.convertStringToDate(currentWeek.last['Date'] as String); + + final dateRangeLabel = isArabic + ? '${firstDate.day} ${DateUtil.getMonthArabic(firstDate.month).trim()} – ' + '${lastDate.day} ${DateUtil.getMonthArabic(lastDate.month).trim()}, ${lastDate.year}' + : '${DateFormat('MMM dd', 'en_US').format(firstDate)} – ' + '${DateFormat('MMM dd', 'en_US').format(lastDate)}, ${lastDate.year}'; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 5.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Text( + // "Doctor Schedule", + // style: TextStyle( + // fontSize: 24.h, + // fontWeight: FontWeight.bold, + // color: const Color(0xFF1A1A1A), + // ), + // ), + SizedBox(height: 16.h), + + // Navigation: < date-pill > + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildNavArrow( + icon: Icons.chevron_left, + enabled: currentPage.round() > 0, + onTap: () => _animateToPage(currentPage.round() - 1), + ), + SizedBox(width: 12.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + decoration: BoxDecoration( + color: AppColors.secondaryLightRedColor, + borderRadius: BorderRadius.circular(12.r), + ), + child: dateRangeLabel.toText14( + weight: FontWeight.w600, + color: AppColors.primaryRedColor, + ), + ), + SizedBox(width: 12.w), + _buildNavArrow( + icon: Icons.chevron_right, + enabled: currentPage.round() < weeks.length - 1, + onTap: () => _animateToPage(currentPage.round() + 1), + ), + ], + ), + SizedBox(height: 20.h), + + LocaleKeys.weeklyShift.tr(context: context).toText18( + weight: FontWeight.w600, + color: AppColors.textColor, + ), + ], + ), + ); + } + + void _animateToPage(int index) { + _pageController.animateToPage( + index.clamp(0, weeks.length - 1), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + Widget _buildNavArrow({ + required IconData icon, + required bool enabled, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + width: 34.w, + height: 34.w, + decoration: BoxDecoration( + + borderRadius: BorderRadius.circular(8.h), + color: enabled ? AppColors.secondaryLightRedColor : AppColors.greyF7Color, + ), + child: Icon( + icon, + size: 28.h, + color: enabled ? AppColors.primaryRedColor : AppColors.checkBoxBorderColor, + ), + ), + ); + } + + // ── Week / day views ─────────────────────────────────────────────────────── + + Widget _buildWeekView(int weekIndex, bool isArabic) { + final weekSchedule = weeks[weekIndex]; + return Directionality( + textDirection: isArabic ? ui.TextDirection.rtl : ui.TextDirection.ltr, + child: ListView.builder( + // primary: false detaches this ListView from the PrimaryScrollController + // so it doesn't fight with CollapsingListView's outer CustomScrollView. + primary: false, + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.only(top: 8.h, bottom: 24.h), + itemCount: weekSchedule.length, + itemBuilder: (context, dayIndex) => _buildDayCard(context, weekSchedule[dayIndex]), + ), + ); + } + + Widget _buildDayCard(BuildContext context, Map daySchedule) { + final date = DateUtil.convertStringToDate(daySchedule['Date'] as String); + final hasFreeSlots = !(daySchedule['fullDay'] as bool? ?? true); + final workingHours = (daySchedule['WorkingHours'] as String?) ?? ''; + final isOff = workingHours.isEmpty || + workingHours.toLowerCase().contains('off') || + workingHours.toLowerCase().contains('not available'); + + final shifts = workingHours.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: hasFreeSlots ? () => getIt.get().pop() : null, + borderRadius: BorderRadius.circular(20.r), + child: Container( + margin: EdgeInsets.only(bottom: 12.h, left: 5.w, right: 5.w), + padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 16.h), + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(20.r), + boxShadow: [ + BoxShadow( + blurRadius: 16, + spreadRadius: 0, + color: Colors.black.withValues(alpha: 0.02), + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Left — day name + short date + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + daySchedule['DayName'].toString().toText16( + weight: FontWeight.w700, + color: AppColors.textColor, + ), + SizedBox(height: 4.h), + _getDayMonth(date).toText13( + color: AppColors.greyTextColorLight, + ), + ], + ), + ), + + // Right — time slots or "Off" + if (isOff) + LocaleKeys.dayOff.tr(context: context).toText14( + weight: FontWeight.w500, + color: AppColors.greyTextColorLight, + ) + else + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var i = 0; i < shifts.length; i++) ...[ + shifts[i].toText14( + weight: FontWeight.w500, + color: AppColors.textColor, + ), + if (i < shifts.length - 1) SizedBox(height: 6.h), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + bool _hasFreeSlot(String jsonDate) { + if (jsonDate.isEmpty) return false; + final date = DateUtil.convertStringToDate(jsonDate); + final scheduleDay = DateTime(date.year, date.month, date.day); + + for (final event in freeSlots) { + try { + final eventDate = DateUtil.convertStringToDate(event); + final nDate = DateTime(eventDate.year, eventDate.month, eventDate.day); + if (nDate.millisecondsSinceEpoch == scheduleDay.millisecondsSinceEpoch) return true; + } catch (_) { + continue; + } + } + return false; + } + + String _getDayMonth(DateTime dateTime) { + final isArabic = appState.isArabic(); + if (isArabic) { + return '${dateTime.day} ${DateUtil.getMonthArabic(dateTime.month)}'; + } + return '${dateTime.day} ${DateUtil.getMonth(dateTime.month).substring(0, 3)}'; + } +} + diff --git a/lib/presentation/book_appointment/widgets/doctor_schedule_page.dart b/lib/presentation/book_appointment/widgets/doctor_schedule_page.dart new file mode 100644 index 00000000..6e2560e2 --- /dev/null +++ b/lib/presentation/book_appointment/widgets/doctor_schedule_page.dart @@ -0,0 +1,39 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; +import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/doctor_schedule_bottom_sheet.dart'; +import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart'; + +class DoctorSchedulePage extends StatelessWidget { + const DoctorSchedulePage({super.key}); + + @override + Widget build(BuildContext context) { + return CollapsingListView( + title: LocaleKeys.doctorSchedule.tr(), + isClose: true, + child: Builder( + builder: (ctx) { + final mq = MediaQuery.of(ctx); + // SliverToBoxAdapter is unbounded — give explicit height so + // the PageView (Expanded) inside DoctorScheduleBottomSheet works. + final height = mq.size.height - mq.padding.top - 56.h; + return SizedBox( + height: height, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + // Stop scroll notifications from bubbling up to CollapsingListView's + // outer CustomScrollView, which would steal vertical scroll gestures. + child: NotificationListener( + onNotification: (_) => true, + child: const DoctorScheduleBottomSheet(), + ), + ), + ); + }, + ), + ); + } +} + From 948416da7415150f87ed8866f603be93e597621a Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Tue, 30 Jun 2026 10:10:41 +0300 Subject: [PATCH 07/10] Update to stores VersionID 21.4 --- lib/core/api_consts.dart | 2 +- lib/core/app_state.dart | 2 +- pubspec.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index 664432fd..089f371b 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -301,7 +301,7 @@ class ApiConsts { static String googleCloudStorageENTranslationFileBaseURL = "https://storage.googleapis.com/hmg-patientapp-translations"; // ************ static values for Api **************** - static final double appVersionID = 21.3; + static final double appVersionID = 21.4; // static final double appVersionID = 50.7; static final int appChannelId = 3; diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart index 2731cc1a..a27b727e 100644 --- a/lib/core/app_state.dart +++ b/lib/core/app_state.dart @@ -56,7 +56,7 @@ class AppState { void setIsPayfortHostedPageEnabled(bool value) { isPayfortHostedPageEnabled = value; - // isPayfortHostedPageEnabled = true; + // isPayfortHostedPageEnabled = false; } int? _superUserID; diff --git a/pubspec.yaml b/pubspec.yaml index ba0c0ed5..eafbd513 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,8 +2,8 @@ name: hmg_patient_app_new description: "New HMG Patient App" publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 0.0.37+36 -#version: 0.0.6+3 +#version: 0.0.38+37 +version: 0.0.7+1 environment: sdk: ">=3.6.0 <4.0.0" From e867de7b71534f0fca8898fe0fa58a363deb2712 Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Tue, 30 Jun 2026 15:57:28 +0300 Subject: [PATCH 08/10] updates --- lib/features/authentication/authentication_view_model.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/features/authentication/authentication_view_model.dart b/lib/features/authentication/authentication_view_model.dart index bb6def9e..b51b36e4 100644 --- a/lib/features/authentication/authentication_view_model.dart +++ b/lib/features/authentication/authentication_view_model.dart @@ -1209,13 +1209,15 @@ class AuthenticationViewModel extends ChangeNotifier { } // Fetch profile image after switching user - await _fetchProfileImage(activation.list!.first.patientId); + // await _fetchProfileImage(activation.list!.first.patientId); + _fetchProfileImage(activation.list!.first.patientId); } else { activation.list!.first.isParentUser = true; _appState.addUserSwitchPayload(activation.list!.first.toJson(), isSuperUser: true); // Fetch profile image after login - await _fetchProfileImage(activation.list!.first.patientId); + // await _fetchProfileImage(activation.list!.first.patientId); + _fetchProfileImage(activation.list!.first.patientId); } activation.list!.first.bloodGroup = activation.patientBloodType; activation.list!.first.zipCode = selectedCountrySignup == CountryEnum.others ? '0' : selectedCountrySignup.countryCode; From 13f2898a8bf30514103dd757f387fd4d3bf3e658 Mon Sep 17 00:00:00 2001 From: Haroon Amjad <> Date: Tue, 30 Jun 2026 20:47:33 +0300 Subject: [PATCH 09/10] Fixes for Apple Watch integration --- .../health_data_transformations.dart | 151 ++++++++++-------- .../health_service.dart | 16 +- .../model/vitals_data_model.dart | 14 +- .../health_connect_helper.dart | 49 +++++- .../smartwatches/activity_detail.dart | 3 +- 5 files changed, 148 insertions(+), 85 deletions(-) diff --git a/lib/features/smartwatch_health_data/health_data_transformations.dart b/lib/features/smartwatch_health_data/health_data_transformations.dart index 6f777353..1aeff65b 100644 --- a/lib/features/smartwatch_health_data/health_data_transformations.dart +++ b/lib/features/smartwatch_health_data/health_data_transformations.dart @@ -13,80 +13,90 @@ enum Durations { yearly("yearly"); final String value; + const Durations(this.value); } class HealthDataTransformation { - Map> transformVitalsToDataPoints(VitalsWRTType vitals, String filterType, String selectedSection,) { - final Map> dataPointMap = {}; + Map> transformVitalsToDataPoints( + VitalsWRTType vitals, + String filterType, + String selectedSection, + ) { + final Map> dataPointMap = {}; Map> data = vitals.getVitals(); // Group data based on the filter type Map> groupedData = {}; // List > items = data.values.toList(); List keys = data.keys.toList(); var count = 0; - List item = data[selectedSection] ?? []; + List item = data[selectedSection] ?? []; // for(var item in items) { - List dataPoints = []; - - for (var vital in item) { - String key = ""; - if (vital.value == "" || vital.timestamp == "") continue; - var parseDate = DateTime.parse(vital.timestamp); - var currentDate = normalizeToStartOfDay(DateTime.now()); - if (filterType == Durations.daily.value) { - if(isBetweenInclusive(parseDate, currentDate, DateTime.now())) { - key = DateFormat('yyyy-MM-dd HH').format(DateTime.parse(vital.timestamp)); - groupedData.putIfAbsent(key, () => []).add(vital); - }// Group by hour - } else if (filterType == Durations.weekly.value) { - if(isBetweenInclusive(parseDate, currentDate.subtract(Duration(days: 7)), DateTime.now())) { - key = DateFormat('yyyy-MM-dd').format(DateTime.parse(vital.timestamp)); - groupedData.putIfAbsent(key, () => []).add(vital); - - } // Group by day - } else if (filterType == Durations.monthly.value) { - if(isBetweenInclusive(parseDate, currentDate.subtract(Duration(days: 30)), DateTime.now())) { - print("the value for the monthly filter is ${vital.value} with the timestamp ${vital.timestamp} and the current date is $currentDate and the parse date is $parseDate"); - key = DateFormat('yyyy-MM-dd').format(DateTime.parse(vital.timestamp)); - groupedData.putIfAbsent(key, () => []).add(vital); - - } // Group by day - } else if (filterType == Durations.halfYearly.value || filterType == Durations.yearly.value) { - if(isBetweenInclusive(parseDate, currentDate.subtract(Duration(days: filterType == Durations.halfYearly.value?180: 365)), DateTime.now())) { - key = DateFormat('yyyy-MM').format(DateTime.parse(vital.timestamp)); - groupedData.putIfAbsent(key, () => []).add(vital); - - } // Group by month - } else { - throw ArgumentError('Invalid filter type'); - } + List dataPoints = []; + + for (var vital in item) { + String key = ""; + if (vital.value == "" || vital.timestamp == "") continue; + + DateTime parseDate; + try { + parseDate = DateTime.parse(vital.timestamp); + } catch (e) { + print('Error parsing timestamp "${vital.timestamp}": $e'); + continue; // Skip this vital if timestamp is invalid + } + + var currentDate = normalizeToStartOfDay(DateTime.now()); + if (filterType == Durations.daily.value) { + if (isBetweenInclusive(parseDate, currentDate, DateTime.now())) { + key = DateFormat('yyyy-MM-dd HH').format(parseDate); + groupedData.putIfAbsent(key, () => []).add(vital); + } // Group by hour + } else if (filterType == Durations.weekly.value) { + if (isBetweenInclusive(parseDate, currentDate.subtract(Duration(days: 7)), DateTime.now())) { + key = DateFormat('yyyy-MM-dd').format(parseDate); + groupedData.putIfAbsent(key, () => []).add(vital); + } // Group by day + } else if (filterType == Durations.monthly.value) { + if (isBetweenInclusive(parseDate, currentDate.subtract(Duration(days: 30)), DateTime.now())) { + print("the value for the monthly filter is ${vital.value} with the timestamp ${vital.timestamp} and the current date is $currentDate and the parse date is $parseDate"); + key = DateFormat('yyyy-MM-dd').format(parseDate); + groupedData.putIfAbsent(key, () => []).add(vital); + } // Group by day + } else if (filterType == Durations.halfYearly.value || filterType == Durations.yearly.value) { + if (isBetweenInclusive(parseDate, currentDate.subtract(Duration(days: filterType == Durations.halfYearly.value ? 180 : 365)), DateTime.now())) { + key = DateFormat('yyyy-MM').format(parseDate); + groupedData.putIfAbsent(key, () => []).add(vital); + } // Group by month + } else { + throw ArgumentError('Invalid filter type'); } - print("the size of groupData is ${groupedData.values.length}"); - - // Process grouped data - groupedData.forEach((key, values) { - double sum = values.fold(0, (acc, v) => acc + num.parse(v.value)); - double mean = sum / values.length; - if(selectedSection == "bodyOxygen" || selectedSection == "bodyTemperature") { - mean = sum / values.length; - }else { - mean = sum; - } - - double finalValue = mean; - print("the final value is $finalValue for the key $key with the original values ${values.map((v) => v.value).toList()} and uom is ${values.first.unitOfMeasure}"); - dataPoints.add(DataPoint( - value: smartScale(finalValue), - label: key, - actualValue: finalValue.toStringAsFixed(2), - displayTime: key, - unitOfMeasurement:values.first.unitOfMeasure , - time: DateTime.parse(values.first.timestamp), - )); - }); - - dataPointMap[filterType] = dataPoints; + } + print("the size of groupData is ${groupedData.values.length}"); + + // Process grouped data + groupedData.forEach((key, values) { + double sum = values.fold(0, (acc, v) => acc + num.parse(v.value)); + double mean = sum / values.length; + if (selectedSection == "bodyOxygen" || selectedSection == "bodyTemperature") { + mean = sum / values.length; + } else { + mean = sum; + } + + double finalValue = mean; + print("the final value is $finalValue for the key $key with the original values ${values.map((v) => v.value).toList()} and uom is ${values.first.unitOfMeasure}"); + dataPoints.add(DataPoint( + value: smartScale(finalValue), + label: key, + actualValue: finalValue.toStringAsFixed(2), + displayTime: key, + unitOfMeasurement: values.first.unitOfMeasure, + time: DateTime.parse(values.first.timestamp), + )); + }); + + dataPointMap[filterType] = dataPoints; // } return dataPointMap; } @@ -109,10 +119,10 @@ class HealthDataTransformation { final random = Random(); // Smooth compression scaling - double baseScaled = number <20 ? number:100 * (number / (number + 100)); + double baseScaled = number < 20 ? number : 100 * (number / (number + 100)); // Random factor between 0.9 and 1.1 (±10%) - double randomFactor = number <20 ? random.nextDouble() * 1.5: 0.9 + random.nextDouble() * 0.2; + double randomFactor = number < 20 ? random.nextDouble() * 1.5 : 0.9 + random.nextDouble() * 0.2; double result = baseScaled * randomFactor; @@ -122,13 +132,12 @@ class HealthDataTransformation { DateTime normalizeToStartOfDay(DateTime date) { return DateTime(date.year, date.month, date.day); } + bool isBetweenInclusive( - DateTime target, - DateTime start, - DateTime end, - ) { + DateTime target, + DateTime start, + DateTime end, + ) { return !normalizeToStartOfDay(target).isBefore(start) && !normalizeToStartOfDay(target).isAfter(end); } - - -} \ No newline at end of file +} diff --git a/lib/features/smartwatch_health_data/health_service.dart b/lib/features/smartwatch_health_data/health_service.dart index 78411bfb..9e6c91b2 100644 --- a/lib/features/smartwatch_health_data/health_service.dart +++ b/lib/features/smartwatch_health_data/health_service.dart @@ -172,12 +172,12 @@ class HealthService { } } - void addWatchHelper(SmartWatchTypes watchType){ - watchHelper = CreateWatchHelper.getWatchName(watchType) ; + void addWatchHelper(SmartWatchTypes watchType) { + watchHelper = CreateWatchHelper.getWatchName(watchType); } Future> initDevice() async { - if(watchHelper == null){ + if (watchHelper == null) { return Result.error('No watch helper found'); } return await watchHelper!.initDevice(); @@ -197,14 +197,14 @@ class HealthService { await watchHelper!.getDistance(); await watchHelper!.getBloodOxygen(); Result data = await watchHelper!.retrieveData(); - if(data.isError) { + if (data.isError) { print('Unable to get the data'); } - var response = jsonDecode(data.asValue?.value?.toString()?.trim().replaceAll("\n", "")??""); - VitalsWRTType vitals = VitalsWRTType.fromMap(response); - log("the data is ${vitals}"); + var response = jsonDecode(data.asValue?.value?.toString()?.trim().replaceAll("\n", "") ?? ""); + VitalsWRTType vitals = VitalsWRTType.fromMap(response); + // log("the data is ${vitals}"); return vitals; - }catch(e){ + } catch (e) { print('Error getting heart rate: $e'); } return null; diff --git a/lib/features/smartwatch_health_data/model/vitals_data_model.dart b/lib/features/smartwatch_health_data/model/vitals_data_model.dart index ba1ac9f1..83cd4ea6 100644 --- a/lib/features/smartwatch_health_data/model/vitals_data_model.dart +++ b/lib/features/smartwatch_health_data/model/vitals_data_model.dart @@ -9,10 +9,22 @@ class Vitals { this.unitOfMeasure = "", }); + Map toJson() { + return { + 'value': value, + 'timestamp': timestamp, + 'uom': unitOfMeasure, + }; + } + factory Vitals.fromMap(Map map) { + String timestamp = map['timestamp'] ?? map['timeStamp'] ?? ""; + if (timestamp.isEmpty) { + timestamp = DateTime.now().toIso8601String(); + } return Vitals( value: map['value'] ?? "", - timestamp: map['timeStamp'] ?? "", + timestamp: timestamp, unitOfMeasure: map['uom'] ?? "", ); } diff --git a/lib/features/smartwatch_health_data/watch_connectors/health_connect_helper.dart b/lib/features/smartwatch_health_data/watch_connectors/health_connect_helper.dart index b77b5430..aeca1e87 100644 --- a/lib/features/smartwatch_health_data/watch_connectors/health_connect_helper.dart +++ b/lib/features/smartwatch_health_data/watch_connectors/health_connect_helper.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:async/src/result/result.dart'; import 'package:flutter/material.dart'; @@ -80,7 +81,10 @@ class HealthConnectHelper extends WatchHelper { @override Future retrieveData() async { + // String retData; + // retData = Result.value(getMappedData()) as String; return Result.value(getMappedData()); + // return retData; } @override @@ -151,9 +155,22 @@ class HealthConnectHelper extends WatchHelper { for (var point in data) { if (point.value is NumericHealthValue) { final numericValue = (point.value as NumericHealthValue).numericValue; + String timestamp = DateTime.now().toIso8601String(); + + try { + if (point.dateFrom != null) { + String tempTimestamp = point.dateFrom.toIso8601String(); + if (tempTimestamp.isNotEmpty) { + timestamp = tempTimestamp; + } + } + } catch (e) { + debugPrint('Error converting dateFrom to ISO8601 for $s: $e'); + } + Vitals vitals = Vitals( - value: (point.value as NumericHealthValue).numericValue.toStringAsFixed(2), - timestamp: point.dateFrom.toString() + value: numericValue.toStringAsFixed(2), + timestamp: timestamp ); mappedData[s]?.add(vitals); } @@ -165,17 +182,41 @@ class HealthConnectHelper extends WatchHelper { for (var point in data) { if (point.value is NumericHealthValue) { final numericValue = (point.value as NumericHealthValue).numericValue; + String timestamp = DateTime.now().toIso8601String(); + + try { + if (point.dateFrom != null) { + String tempTimestamp = point.dateFrom.toIso8601String(); + if (tempTimestamp.isNotEmpty) { + timestamp = tempTimestamp; + } + } + } catch (e) { + debugPrint('Error converting dateFrom to ISO8601 for $s: $e'); + } + point.value = NumericHealthValue( numericValue: numericValue * 100, ); - Vitals vitals = Vitals(value: (point.value as NumericHealthValue).numericValue.toStringAsFixed(2), timestamp: point.dateFrom.toString()); + Vitals vitals = Vitals( + value: (point.value as NumericHealthValue).numericValue.toStringAsFixed(2), + timestamp: timestamp + ); mappedData[s]?.add(vitals); } } } getMappedData() { - return " { \"heartRate\": ${mappedData["heartRate"] ?? []}, \"sleep\": ${mappedData["sleep"] ?? []}, \"steps\": ${mappedData["steps"] ?? []}, \"activity\": ${mappedData["activity"] ?? []}, \"bloodOxygen\": ${mappedData["bloodOxygen"] ?? []}, \"bodyTemperature\": ${mappedData["bodyTemperature"] ?? []}, \"distance\": ${mappedData["distance"] ?? []} }"; + return jsonEncode({ + "heartRate": (mappedData["heartRate"] ?? []).map((v) => v.toJson()).toList(), + "sleep": (mappedData["sleep"] ?? []).map((v) => v.toJson()).toList(), + "steps": (mappedData["steps"] ?? []).map((v) => v.toJson()).toList(), + "activity": (mappedData["activity"] ?? []).map((v) => v.toJson()).toList(), + "bloodOxygen": (mappedData["bloodOxygen"] ?? []).map((v) => v.toJson()).toList(), + "bodyTemperature": (mappedData["bodyTemperature"] ?? []).map((v) => v.toJson()).toList(), + "distance": (mappedData["distance"] ?? []).map((v) => v.toJson()).toList(), + }); } getHeartData(DateTime startDate, DateTime endDate, HealthDataType types) async { diff --git a/lib/presentation/smartwatches/activity_detail.dart b/lib/presentation/smartwatches/activity_detail.dart index b023e04f..7911baf6 100644 --- a/lib/presentation/smartwatches/activity_detail.dart +++ b/lib/presentation/smartwatches/activity_detail.dart @@ -240,7 +240,8 @@ class _ActivityDetailsState extends State { return Selector>?>( selector: (_, model) => model.selectedData, builder: (_, data, __) { - if (context.read().selectedData.values.toList().first?.isEmpty == true) return SizedBox(); + // if (context.read().selectedData.values.toList().first.isEmpty == true) return SizedBox(); + if (context.read().selectedData.values.toList().isEmpty == true) return SizedBox(); return CustomBarChart( dataPoints: context.read().selectedData.values.toList().first, From 753968d3fa060b0302f0cc56688d50cf6bf1f9c0 Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Wed, 1 Jul 2026 14:16:29 +0300 Subject: [PATCH 10/10] updates for dental & laser clinics --- .../search_doctor_by_name.dart | 1 + .../book_appointment/widgets/doctor_card.dart | 182 ++++++++++-------- 2 files changed, 107 insertions(+), 76 deletions(-) diff --git a/lib/presentation/book_appointment/search_doctor_by_name.dart b/lib/presentation/book_appointment/search_doctor_by_name.dart index 438cd746..165acfbf 100644 --- a/lib/presentation/book_appointment/search_doctor_by_name.dart +++ b/lib/presentation/book_appointment/search_doctor_by_name.dart @@ -212,6 +212,7 @@ class _SearchDoctorByNameState extends State { doctorsListResponseModel: bookAppointmentsVM.filteredDoctorList[index], isLoading: false, bookAppointmentsViewModel: bookAppointmentsViewModel, + isDoctorNameSearch: true, ).paddingSymmetrical(16.h, 0.h).onPress(() async { bookAppointmentsVM.setSelectedDoctor(bookAppointmentsVM.filteredDoctorList[index]); LoaderBottomSheet.showLoader(); diff --git a/lib/presentation/book_appointment/widgets/doctor_card.dart b/lib/presentation/book_appointment/widgets/doctor_card.dart index 7a39bc3e..fe7cf3e0 100644 --- a/lib/presentation/book_appointment/widgets/doctor_card.dart +++ b/lib/presentation/book_appointment/widgets/doctor_card.dart @@ -10,13 +10,17 @@ 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/book_appointments/book_appointments_view_model.dart'; import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/doctors_list_response_model.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/get_clinic_list_response_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/presentation/book_appointment/dental_chief_complaints_page.dart'; +import 'package:hmg_patient_app_new/presentation/book_appointment/laser/laser_appointment.dart'; import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/appointment_calendar.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/chip/app_custom_chip_widget.dart'; import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; 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:lottie/lottie.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -25,11 +29,13 @@ class DoctorCard extends StatelessWidget { super.key, required this.doctorsListResponseModel, required this.isLoading, + this.isDoctorNameSearch = false, required this.bookAppointmentsViewModel, }); final DoctorsListResponseModel doctorsListResponseModel; final bool isLoading; + final bool isDoctorNameSearch; final BookAppointmentsViewModel bookAppointmentsViewModel; @@ -173,78 +179,47 @@ class DoctorCard extends StatelessWidget { CustomButton( text: LocaleKeys.bookAppo.tr(context: context), onPressed: () async { - bookAppointmentsViewModel.setSelectedDoctor(doctorsListResponseModel); - LoaderBottomSheet.showLoader(); - bookAppointmentsViewModel.isLiveCareSchedule - ? await bookAppointmentsViewModel.getLiveCareDoctorFreeSlots( - isBookingForLiveCare: true, - onSuccess: (dynamic respData) async { - LoaderBottomSheet.hideLoader(); - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.pickADate.tr(context: context), - context, - child: AppointmentCalendar(), - isFullScreen: false, - isCloseButtonVisible: true, - callBackFunc: () {}, - ); - }, - onError: (err) { - LoaderBottomSheet.hideLoader(); - showCommonBottomSheetWithoutHeight( - context, - child: Utils.getErrorWidget(loadingText: err), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - }) - : await bookAppointmentsViewModel.getDoctorFreeSlots( - isBookingForLiveCare: false, - onSuccess: (dynamic respData) async { - LoaderBottomSheet.hideLoader(); - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.pickADate.tr(context: context), - context, - child: AppointmentCalendar(), - isFullScreen: false, - isCloseButtonVisible: true, - callBackFunc: () {}, - ); - }, - onError: (err) { - LoaderBottomSheet.hideLoader(); - if (bookAppointmentsViewModel.selectedClinic.clinicID == 23) { + if (isDoctorNameSearch && doctorsListResponseModel.clinicID == 17) { + GetClinicsListResponseModel selectedClinic = GetClinicsListResponseModel( + clinicID: doctorsListResponseModel.clinicID, clinicDescription: doctorsListResponseModel.clinicName, isLiveCareClinicAndOnline: false, liveCareServiceID: 0, liveCareClinicID: 0); + bookAppointmentsViewModel.setProjectID(doctorsListResponseModel.projectID.toString()); + bookAppointmentsViewModel.setSelectedClinic(selectedClinic); + bookAppointmentsViewModel.setIsChiefComplaintsListLoading(true); + Navigator.of(context).push( + CustomPageRoute( + page: DentalChiefComplaintsPage(), + ), + ); + } else if (isDoctorNameSearch && doctorsListResponseModel.clinicID == 253) { + GetClinicsListResponseModel selectedClinic = GetClinicsListResponseModel( + clinicID: doctorsListResponseModel.clinicID, clinicDescription: doctorsListResponseModel.clinicName, isLiveCareClinicAndOnline: false, liveCareServiceID: 0, liveCareClinicID: 0); + bookAppointmentsViewModel.setSelectedClinic(selectedClinic); + bookAppointmentsViewModel.getLaserClinic(); + bookAppointmentsViewModel.setIsDoctorsListLoading(true); + Navigator.of(context).push( + CustomPageRoute( + page: LaserAppointment(), + ), + ); + } else { + bookAppointmentsViewModel.setSelectedDoctor(doctorsListResponseModel); + LoaderBottomSheet.showLoader(); + bookAppointmentsViewModel.isLiveCareSchedule + ? await bookAppointmentsViewModel.getLiveCareDoctorFreeSlots( + isBookingForLiveCare: true, + onSuccess: (dynamic respData) async { + LoaderBottomSheet.hideLoader(); showCommonBottomSheetWithoutHeight( + title: LocaleKeys.pickADate.tr(context: context), context, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Lottie.asset(AppAnimations.errorAnimation, repeat: true, reverse: false, frameRate: FrameRate(60), width: 100.h, height: 100.h, fit: BoxFit.fill), - SizedBox(height: 8.h), - ("For booking appointments in the Physiotherapy clinic, please contact: 920066666").toText16(color: AppColors.blackColor), - SizedBox(height: 16.h), - CustomButton( - height: 40.h, - backgroundColor: AppColors.primaryRedColor, - borderColor: AppColors.primaryRedColor, - text: LocaleKeys.callNow.tr(), - textColor: AppColors.whiteColor, - iconColor: AppColors.whiteColor, - onPressed: () { - launchUrl( - Uri.parse("tel://920066666"), - ); - }, - icon: AppAssets.call), - ], - ).center, - callBackFunc: () {}, + child: AppointmentCalendar(), isFullScreen: false, isCloseButtonVisible: true, + callBackFunc: () {}, ); - } else { + }, + onError: (err) { + LoaderBottomSheet.hideLoader(); showCommonBottomSheetWithoutHeight( context, child: Utils.getErrorWidget(loadingText: err), @@ -252,15 +227,70 @@ class DoctorCard extends StatelessWidget { isFullScreen: false, isCloseButtonVisible: true, ); - } - // showCommonBottomSheetWithoutHeight( - // context, - // child: Utils.getErrorWidget(loadingText: err), - // callBackFunc: () {}, - // isFullScreen: false, - // isCloseButtonVisible: true, - // ); - }); + }) + : await bookAppointmentsViewModel.getDoctorFreeSlots( + isBookingForLiveCare: false, + onSuccess: (dynamic respData) async { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + title: LocaleKeys.pickADate.tr(context: context), + context, + child: AppointmentCalendar(), + isFullScreen: false, + isCloseButtonVisible: true, + callBackFunc: () {}, + ); + }, + onError: (err) { + LoaderBottomSheet.hideLoader(); + if (bookAppointmentsViewModel.selectedClinic.clinicID == 23) { + showCommonBottomSheetWithoutHeight( + context, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Lottie.asset(AppAnimations.errorAnimation, repeat: true, reverse: false, frameRate: FrameRate(60), width: 100.h, height: 100.h, fit: BoxFit.fill), + SizedBox(height: 8.h), + ("For booking appointments in the Physiotherapy clinic, please contact: 920066666").toText16(color: AppColors.blackColor), + SizedBox(height: 16.h), + CustomButton( + height: 40.h, + backgroundColor: AppColors.primaryRedColor, + borderColor: AppColors.primaryRedColor, + text: LocaleKeys.callNow.tr(), + textColor: AppColors.whiteColor, + iconColor: AppColors.whiteColor, + onPressed: () { + launchUrl( + Uri.parse("tel://920066666"), + ); + }, + icon: AppAssets.call), + ], + ).center, + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } else { + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } + // showCommonBottomSheetWithoutHeight( + // context, + // child: Utils.getErrorWidget(loadingText: err), + // callBackFunc: () {}, + // isFullScreen: false, + // isCloseButtonVisible: true, + // ); + }); + } }, backgroundColor: Color(0xffFEE9EA), borderColor: Color(0xffFEE9EA),