From 6abcdf135ecaad6f7fa1cb8864486f0f518880d2 Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Wed, 24 Jun 2026 14:40:45 +0300 Subject: [PATCH] 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; + } +} +