From 67bb3895e206551610901ffa287ccef542d30f6c Mon Sep 17 00:00:00 2001 From: faizatflutter Date: Sun, 17 May 2026 09:16:33 +0300 Subject: [PATCH] Refund Module almost done --- lib/core/api/api_client.dart | 4 +- lib/core/utils/validation_utils.dart | 29 ++--- .../habib_wallet/habib_wallet_view_model.dart | 100 +++++++++++++++++- .../submit_refund_request_model.dart | 87 +++++++++++---- .../refund_request_view_model.dart | 4 +- .../withdraw_request_create_page.dart | 86 ++++++++++++++- 6 files changed, 266 insertions(+), 44 deletions(-) diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 8b1e288f..7b104ab3 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -209,8 +209,8 @@ class ApiClientImp implements ApiClient { } // TODO: REMOVE THIS MUST - body['TokenID'] = "@dm!n"; - body['PatientID'] = 4768571; + // body['TokenID'] = "@dm!n"; + // body['PatientID'] = 4768571; // body['PatientTypeID'] = 1; // body['PatientOutSA'] = 0; // body['SessionID'] = "45786230487560q"; diff --git a/lib/core/utils/validation_utils.dart b/lib/core/utils/validation_utils.dart index b5651648..b5ba4bae 100644 --- a/lib/core/utils/validation_utils.dart +++ b/lib/core/utils/validation_utils.dart @@ -33,7 +33,7 @@ class ValidationUtils { isCorrectID = false; } - if(nationalId!.length == 10) { + if (nationalId!.length == 10) { if (Utils.isSAUDIIDValid(nationalId!) == false) { _dialogService.showExceptionBottomSheet(message: LocaleKeys.enterValidNationalId.tr(), onOkPressed: onOkPress); isCorrectID = false; @@ -77,10 +77,7 @@ class ValidationUtils { // Check if "Others" country is selected and phone number starts with restricted codes if (selectedCountry == CountryEnum.others) { if (phoneNumber.startsWith('00966') || phoneNumber.startsWith('00971')) { - _dialogService.showExceptionBottomSheet( - message: LocaleKeys.cannotEnterSaudiOrUAENumber.tr(), - onOkPressed: onOkPress - ); + _dialogService.showExceptionBottomSheet(message: LocaleKeys.cannotEnterSaudiOrUAENumber.tr(), onOkPressed: onOkPress); return false; } } @@ -121,7 +118,8 @@ class ValidationUtils { return regex.hasMatch(id); } - static bool validateUaeRegistration({String? name, GenderTypeEnum? gender, NationalityCountries? country, MaritalStatusTypeEnum? maritalStatus, required Function() onOkPress}) { + static bool validateUaeRegistration( + {String? name, GenderTypeEnum? gender, NationalityCountries? country, MaritalStatusTypeEnum? maritalStatus, required Function() onOkPress}) { if (name == null || name.isEmpty) { _dialogService.showExceptionBottomSheet(message: LocaleKeys.pleaseEnterAValidName.tr(), onOkPressed: onOkPress); return false; @@ -160,7 +158,8 @@ class ValidationUtils { return true; } - static bool isValidatedIdAndPhoneWithCountryValidation({String? nationalId, String? phoneNumber, required Function() onOkPress, CountryEnum? selectedCountry}) { + static bool isValidatedIdAndPhoneWithCountryValidation( + {String? nationalId, String? phoneNumber, required Function() onOkPress, CountryEnum? selectedCountry}) { bool isCorrectID = true; if (nationalId == null || nationalId.isEmpty) { _dialogService.showExceptionBottomSheet(message: LocaleKeys.pleaseEnterAnationalID.tr(), onOkPressed: onOkPress); @@ -182,7 +181,7 @@ class ValidationUtils { } } - if (phoneNumber == null || phoneNumber.isEmpty) { + if (phoneNumber == null || phoneNumber.isEmpty) { _dialogService.showExceptionBottomSheet(message: LocaleKeys.enterValidPhoneNumber.tr(), onOkPressed: onOkPress); return false; } @@ -190,9 +189,13 @@ class ValidationUtils { return isCorrectID; } - static bool isNullOrEmpty(String? value) { - return value == null || value - .trim() - .isEmpty; + static bool isNullOrEmpty(String? value) { + return value == null || value.trim().isEmpty; } -} \ No newline at end of file + + /// Validates a KSA IBAN: must start with SA + 2 check digits + 20 alphanumeric chars (24 chars total). + static bool validateKsaIban(String iban) { + final ksaIbanRegex = RegExp(r'^SA\d{2}[0-9A-Z]{20}$', caseSensitive: false); + return ksaIbanRegex.hasMatch(iban.trim().replaceAll(' ', '')); + } +} diff --git a/lib/features/habib_wallet/habib_wallet_view_model.dart b/lib/features/habib_wallet/habib_wallet_view_model.dart index 0353868e..086b0ac2 100644 --- a/lib/features/habib_wallet/habib_wallet_view_model.dart +++ b/lib/features/habib_wallet/habib_wallet_view_model.dart @@ -1,8 +1,12 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/utils/validation_utils.dart'; import 'package:hmg_patient_app_new/features/habib_wallet/habib_wallet_repo.dart'; import 'package:hmg_patient_app_new/features/habib_wallet/models/patient_advance_balance_response_model.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/hospital_model.dart'; +import 'package:hmg_patient_app_new/features/refund_request/models/req_models/submit_refund_request_model.dart'; +import 'package:hmg_patient_app_new/features/refund_request/models/resp_models/refundable_invoices_response_model.dart'; +import 'package:hmg_patient_app_new/features/refund_request/models/resp_models/refund_request_list_response_model.dart'; import 'package:hmg_patient_app_new/features/refund_request/refund_request_repo.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/services/error_handler_service.dart'; @@ -28,6 +32,8 @@ class HabibWalletViewModel extends ChangeNotifier { // ── Refundable Advances ─────────────────────────────────────────────────── bool isAdvancesLoading = false; double totalAdvancesBalance = 0.0; + List refundableAdvancesList = []; + bool isSubmittingWithdrawal = false; bool isSearchedFileNumberDataShown = false; @@ -103,9 +109,7 @@ class HabibWalletViewModel extends ChangeNotifier { _ibanError = LocaleKeys.ibanIsRequired.tr(); isValid = false; } else { - // KSA IBAN format: SA followed by 2 check digits + 18 alphanumeric chars = 22 chars total - final ksaIbanRegex = RegExp(r'^SA\d{2}[0-9A-Z]{18}$', caseSensitive: false); - if (!ksaIbanRegex.hasMatch(iban.trim().replaceAll(' ', ''))) { + if (!ValidationUtils.validateKsaIban(iban.trim())) { _ibanError = LocaleKeys.invalidIbanFormat.tr(); isValid = false; } @@ -158,6 +162,7 @@ class HabibWalletViewModel extends ChangeNotifier { Future fetchAdvancesForHospital() async { isAdvancesLoading = true; totalAdvancesBalance = 0.0; + refundableAdvancesList = []; notifyListeners(); final result = await refundRequestRepo.getRefundableAdvances(); @@ -170,13 +175,86 @@ class HabibWalletViewModel extends ChangeNotifier { (apiResponse) { isAdvancesLoading = false; if (apiResponse.data != null) { - totalAdvancesBalance = apiResponse.data!.refundableAdvancesItems.fold(0.0, (sum, item) => sum + (item.balanceAmount ?? 0.0)); + refundableAdvancesList = apiResponse.data!.refundableAdvancesItems; + totalAdvancesBalance = refundableAdvancesList.fold(0.0, (sum, item) => sum + (item.balanceAmount ?? 0.0)); } notifyListeners(); }, ); } + Future submitWalletWithdrawal({ + required String iban, + required String remarks, + required String patientName, + required String patientId, + required String patientMobileNumber, + required String patientNationalId, + required int projectId, + required String projectName, + Function(SubmitRefundResponseModel)? onSuccess, + Function(String)? onError, + }) async { + isSubmittingWithdrawal = true; + notifyListeners(); + + final advances = refundableAdvancesList + .map((a) => SubmitRefundAdvanceItem( + advanceNo: (a.advanceNo ?? 0).toString(), + paidAmount: a.paidAmount ?? 0.0, + balanceAmount: a.balanceAmount ?? 0.0, + advanceDate: a.advanceDate ?? '', + )) + .toList(); + + final request = SubmitRefundRequestModel( + projectId: projectId, + refundType: '2', // wallet withdrawal + refundProject: projectName, + refundProjectId: projectId, + refundPatientId: patientId, + patientMobileNumber: patientMobileNumber, + refundPatientName: patientName, + refundNationalId: patientNationalId, + refundInvoiceNo: '', + refundAppointmentNo: '', + refundDoctorId: 0, + refundDoctorName: '', + refundClinicId: 0, + refundClinicName: '', + refundDestination: '2', // bank transfer + refundIban: iban, + refundRemarks: remarks, + refundTotalAmount: totalAdvancesBalance, + refundProceduresList: const [], + refundAdvancesList: advances, + ); + + final result = await refundRequestRepo.submitRefund(request: request); + result.fold( + (failure) { + isSubmittingWithdrawal = false; + notifyListeners(); + if (onError != null) { + onError(failure.message); + } else { + errorHandlerService.handleError(failure: failure); + } + }, + (apiResponse) { + isSubmittingWithdrawal = false; + notifyListeners(); + if (apiResponse.data != null) { + if (onSuccess != null) onSuccess(apiResponse.data!); + } else { + if (onError != null) { + onError(apiResponse.errorMessage ?? LocaleKeys.failed.tr()); + } + } + }, + ); + } + initHabibWalletProvider() { isWalletAmountLoading = true; isBottomSheetContentLoading = false; @@ -203,6 +281,20 @@ class HabibWalletViewModel extends ChangeNotifier { notifyListeners(); } + /// Resets all state related to the wallet withdrawal form. + /// Pass [silent] = true when calling from dispose() to avoid notifyListeners + /// while the widget tree is locked. + void resetWithdrawForm({bool silent = false}) { + selectedHospital = null; + totalAdvancesBalance = 0.0; + refundableAdvancesList = []; + isAdvancesLoading = false; + isSubmittingWithdrawal = false; + _ibanError = null; + _withdrawDescriptionError = null; + if (!silent) notifyListeners(); + } + setWalletRechargeAmount(num amount) { walletRechargeAmount = amount; notifyListeners(); diff --git a/lib/features/refund_request/models/req_models/submit_refund_request_model.dart b/lib/features/refund_request/models/req_models/submit_refund_request_model.dart index f1c5ed57..3a8b104c 100644 --- a/lib/features/refund_request/models/req_models/submit_refund_request_model.dart +++ b/lib/features/refund_request/models/req_models/submit_refund_request_model.dart @@ -27,6 +27,33 @@ class SubmitRefundProcedureItem { } } +/// Represents a single advance item in the wallet withdrawal refund payload. +class SubmitRefundAdvanceItem { + final String advanceNo; + final double paidAmount; + final double balanceAmount; + final String advanceDate; + + const SubmitRefundAdvanceItem({ + required this.advanceNo, + required this.paidAmount, + required this.balanceAmount, + required this.advanceDate, + }); + + Map toJson() => { + "AdvanceNo": advanceNo, + "PaidAmount": paidAmount, + "BalanceAmount": balanceAmount, + "AdvanceDate": advanceDate, + }; + + @override + String toString() { + return 'SubmitRefundAdvanceItem{advanceNo: $advanceNo, paidAmount: $paidAmount, balanceAmount: $balanceAmount, advanceDate: $advanceDate}'; + } +} + class SubmitRefundRequestModel { final int projectId; final String refundType; @@ -47,6 +74,8 @@ class SubmitRefundRequestModel { final String refundRemarks; final double refundTotalAmount; final List refundProceduresList; + /// Populated only for wallet withdrawal refunds (refundType == '2'). + final List refundAdvancesList; const SubmitRefundRequestModel({ required this.projectId, @@ -68,32 +97,46 @@ class SubmitRefundRequestModel { required this.refundRemarks, required this.refundTotalAmount, required this.refundProceduresList, + this.refundAdvancesList = const [], }); - Map toJson() => { - "ProjectID": projectId, - "Refund_RefundType": refundType, - "Refund_Project": refundProject, - "Refund_ProjectId": refundProjectId, - "Refund_PatientId": refundPatientId, - "PatientMobileNumber": patientMobileNumber, - "Refund_PatientName": refundPatientName, - "Refund_NationalId": refundNationalId, - "Refund_InvoiceNo": refundInvoiceNo, - "Refund_AppointmentNo": refundAppointmentNo, - "Refund_DoctorId": refundDoctorId, - "Refund_DoctorName": refundDoctorName, - "Refund_ClinicId": refundClinicId, - "Refund_ClinicName": refundClinicName, - "Refund_RefundDestination": refundDestination, - "Refund_IBAN": refundIban, - "Refund_Remarks": refundRemarks, - "Refund_TotalRefundAmount": refundTotalAmount, - "Refund_ProceduresList": refundProceduresList.map((p) => p.toJson()).toList(), - }; + /// refundType '1' = invoice refund → sends Refund_ProceduresList + /// refundType '2' = wallet withdrawal → sends Refund_AdvancesList + Map toJson() { + final Map json = { + "ProjectID": projectId, + "Refund_RefundType": refundType, + "Refund_Project": refundProject, + "Refund_ProjectId": refundProjectId, + "Refund_PatientId": refundPatientId, + "PatientMobileNumber": patientMobileNumber, + "Refund_PatientName": refundPatientName, + "Refund_NationalId": refundNationalId, + "Refund_DoctorId": refundDoctorId, + "Refund_DoctorName": refundDoctorName, + "Refund_RefundDestination": refundDestination, + "Refund_IBAN": refundIban, + "Refund_Remarks": refundRemarks, + "Refund_TotalRefundAmount": refundTotalAmount, + }; + + if (refundType == '2') { + // Wallet withdrawal — include advances list, omit invoice-specific fields + json["Refund_AdvancesList"] = refundAdvancesList.map((a) => a.toJson()).toList(); + } else { + // Invoice refund — include invoice-specific fields and procedures list + json["Refund_InvoiceNo"] = refundInvoiceNo; + json["Refund_AppointmentNo"] = refundAppointmentNo; + json["Refund_ClinicId"] = refundClinicId; + json["Refund_ClinicName"] = refundClinicName; + json["Refund_ProceduresList"] = refundProceduresList.map((p) => p.toJson()).toList(); + } + + return json; + } @override String toString() { - return 'SubmitRefundRequestModel{projectId: $projectId, refundType: $refundType, refundProject: $refundProject, refundProjectId: $refundProjectId, refundPatientId: $refundPatientId, patientMobileNumber: $patientMobileNumber, refundPatientName: $refundPatientName, refundNationalId: $refundNationalId, refundInvoiceNo: $refundInvoiceNo, refundAppointmentNo: $refundAppointmentNo, refundDoctorId: $refundDoctorId, refundDoctorName: $refundDoctorName, refundClinicId: $refundClinicId, refundClinicName: $refundClinicName, refundDestination: $refundDestination, refundIban: $refundIban, refundRemarks: $refundRemarks, refundTotalAmount: $refundTotalAmount, refundProceduresList: $refundProceduresList}'; + return 'SubmitRefundRequestModel{projectId: $projectId, refundType: $refundType, refundAdvancesList: $refundAdvancesList, refundProceduresList: $refundProceduresList}'; } } diff --git a/lib/features/refund_request/refund_request_view_model.dart b/lib/features/refund_request/refund_request_view_model.dart index dcd954b6..51212741 100644 --- a/lib/features/refund_request/refund_request_view_model.dart +++ b/lib/features/refund_request/refund_request_view_model.dart @@ -1,6 +1,7 @@ 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/utils/validation_utils.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/hospital_model.dart'; import 'package:hmg_patient_app_new/features/refund_request/models/req_models/submit_refund_request_model.dart'; import 'package:hmg_patient_app_new/features/refund_request/models/resp_models/refund_request_list_response_model.dart'; @@ -275,8 +276,7 @@ class RefundRequestViewModel extends ChangeNotifier { _ibanError = LocaleKeys.ibanIsRequired.tr(); isValid = false; } else { - final ksaIbanRegex = RegExp(r'^SA\d{2}[0-9A-Z]{20}$', caseSensitive: false); - if (!ksaIbanRegex.hasMatch(iban.trim().replaceAll(' ', ''))) { + if (!ValidationUtils.validateKsaIban(iban.trim())) { _ibanError = LocaleKeys.invalidIbanFormat.tr(); isValid = false; } diff --git a/lib/presentation/habib_wallet/withdraw_request_create_page.dart b/lib/presentation/habib_wallet/withdraw_request_create_page.dart index 11f422e4..b069b441 100644 --- a/lib/presentation/habib_wallet/withdraw_request_create_page.dart +++ b/lib/presentation/habib_wallet/withdraw_request_create_page.dart @@ -19,6 +19,7 @@ import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; import 'package:hmg_patient_app_new/widgets/image_picker.dart'; import 'package:hmg_patient_app_new/widgets/input_widget.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:provider/provider.dart'; @@ -50,6 +51,7 @@ class _WithdrawRequestCreatePageState extends State { @override void dispose() { + _habibWalletViewModel.resetWithdrawForm(silent: true); _bankDetailsController.dispose(); _descriptionController.dispose(); _ibanFocusNode.dispose(); @@ -93,7 +95,7 @@ class _WithdrawRequestCreatePageState extends State { description: _descriptionController.text, ); if (isFormValid) { - // TODO: Submit withdraw request + _handleWalletWithdrawalSubmit(vm); } }, ).paddingSymmetrical(24.h, 24.h), @@ -284,6 +286,88 @@ class _WithdrawRequestCreatePageState extends State { ); } +// ── Submit ──────────────────────────────────────────────────────────────── + + void _handleWalletWithdrawalSubmit(HabibWalletViewModel vm) { + showCommonBottomSheetWithoutHeight( + context, + title: LocaleKeys.notice.tr(context: context), + child: Utils.getWarningWidget( + loadingText: LocaleKeys.confirmSubmitRequest.tr(context: context), + isShowActionButtons: true, + onCancelTap: () { + Navigator.pop(context); + }, + onConfirmTap: () async { + Navigator.pop(context); + LoaderBottomSheet.showLoader(); + + final user = _appState.getAuthenticatedUser(); + final hospital = vm.selectedHospital; + await vm.submitWalletWithdrawal( + iban: _bankDetailsController.text.trim(), + remarks: _descriptionController.text.trim(), + patientName: '${user?.firstName ?? ''} ${user?.lastName ?? ''}'.trim(), + patientId: (user?.patientId ?? 0).toString(), + patientMobileNumber: user?.mobileNumber ?? '', + patientNationalId: user?.patientIdentificationNo?.toString() ?? '', + projectId: hospital?.mainProjectID ?? 0, + projectName: hospital?.name ?? '', + onSuccess: (response) { + LoaderBottomSheet.hideLoader(); + _showWithdrawalSuccessBottomSheet(response.refNo ?? ''); + }, + onError: (err) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + ); + }, + ); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } + + void _showWithdrawalSuccessBottomSheet(String refNo) { + showCommonBottomSheetWithoutHeight( + context, + child: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + children: [ + Utils.getSuccessWidget(loadingText: LocaleKeys.requestSubmittedSuccessfully.tr(context: context)), + SizedBox(height: 24.h), + Row( + children: [ + Expanded( + child: CustomButton( + height: 56.h, + text: LocaleKeys.ok.tr(context: context), + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + textColor: AppColors.whiteColor, + ), + ), + ], + ), + ], + ), + ), + isCloseButtonVisible: false, + isDismissible: false, + isFullScreen: false, + callBackFunc: () {}, + ); + } + // ── Build ───────────────────────────────────────────────────────────────── @override