completed refund CR

faiz_dev
faizatflutter 2 months ago
parent 34a55396b0
commit 3d4609a1a9

@ -0,0 +1,4 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="10" fill="#EFEFF0"/>
<path d="M15.0826 10.25C16.112 10.2496 16.8237 10.2493 17.4626 10.4888C18.8581 11.0121 19.4704 12.2513 19.9425 13.2064C20.0838 13.4892 20.3409 14.0028 20.4128 14.1157C20.4348 14.1526 20.5044 14.2282 20.6064 14.2354C20.7396 14.2492 20.92 14.2501 21.2361 14.2501L24.7905 14.2501H24.7905C25.8095 14.2501 26.6312 14.2501 27.2905 14.3171C27.9711 14.3864 28.5613 14.5332 29.0834 14.8821C29.4929 15.1557 29.8444 15.5072 30.118 15.9167C30.4669 16.4388 30.6137 17.029 30.683 17.7096C30.75 18.3689 30.75 19.2733 30.75 20.2923C30.75 22.0121 30.75 23.3603 30.6408 24.4337C30.5295 25.5284 30.2983 26.4203 29.781 27.1946C29.3614 27.8225 28.8224 28.3615 28.1945 28.781C27.4202 29.2984 26.5283 29.5295 25.4336 29.6409C24.3602 29.7501 23.012 29.7501 21.2923 29.7501H19.9426C17.6342 29.7501 15.8252 29.7501 14.4137 29.5603C12.969 29.3661 11.8289 28.9608 10.9341 28.0659C10.0393 27.1711 9.63399 26.0311 9.43975 24.5864C9.24998 23.1749 9.24999 21.3659 9.25 19.0575V19.0575V15.9096V15.9096C9.24999 15.0308 9.24998 14.3221 9.30044 13.7498C9.35242 13.16 9.46238 12.644 9.7254 12.1727C10.0643 11.5654 10.5653 11.0644 11.1726 10.7255C11.6439 10.4625 12.16 10.3525 12.7497 10.3005C13.322 10.2501 14.2039 10.25 15.0826 10.25Z" fill="#8F9AA3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -1870,6 +1870,7 @@
"selectRefundMethod": "اختر طريقة الاسترداد",
"hmgWallet": "محفظة HMG",
"bankTransfer": "تحويل بنكي",
"cash": "نقدي",
"selectRefundReason": "اختر سبب الاسترداد",
"refundReason": "سبب الاسترداد",
"done": "تم",
@ -1898,5 +1899,12 @@
"invoiceHistory": "سجل الفواتير",
"selectAdvance": "اختر الدفعة المقدمة",
"advanceNo": "رقم الدفعة",
"noAdvancesAvailable": "لا توجد دفعات متاحة للاسترداد"
"noAdvancesAvailable": "لا توجد دفعات متاحة للاسترداد",
"maximumFilesReached": "الحد الأقصى 4 ملفات مسموح به",
"fileSizeTooLarge": "يجب ألا يتجاوز حجم الملف 2 ميغابايت",
"image": "صورة",
"refundCannotBeProcessedPatientAdmitted": "لا يمكن معالجة الاسترداد أثناء إقامة المريض",
"cashRefundLimitExceeded": "يمكنك فقط التقدم بطلب استرداد نقدي إذا كان المبلغ أقل من 5000. لمزيد من المعلومات يرجى زيارتنا.",
"cashRefundInstructions": "بمجرد الموافقة على الطلب، يرجى زيارة صراف مستشفى الحبيب الطبي أو الحبيب للرعاية الأولية لاستلام النقد. عند نافذة الصراف، سوف تتلقى رمز التحقق على رقم هاتفك المسجل. قدم الرمز للصراف للتحقق منه. سيتم صرف النقد فقط بعد التحقق الناجح.",
"cashRefundApprovalInstructions": "بمجرد الموافقة على الطلب،\nيرجى زيارة صراف مستشفى الحبيب الطبي أو الحبيب للرعاية الأولية لاستلام النقد.\nعند نافذة الصراف، سوف تتلقى رمز التحقق على رقم هاتفك المسجل. قدم الرمز للصراف للتحقق منه. سيتم صرف النقد فقط بعد التحقق الناجح."
}

@ -1862,6 +1862,7 @@
"selectRefundMethod": "Select Refund Method",
"hmgWallet": "HMG Wallet",
"bankTransfer": "Bank Transfer",
"cash": "Cash",
"selectRefundReason": "Select Refund Reason",
"refundReason": "Refund Reason",
"done": "Done",
@ -1889,7 +1890,14 @@
"invoiceHistory": "Invoice History",
"selectAdvance": "Select Advance",
"advanceNo": "Advance No",
"noAdvancesAvailable": "No advances available for refund"
"noAdvancesAvailable": "No advances available for refund",
"maximumFilesReached": "Maximum 4 files allowed",
"fileSizeTooLarge": "File size must not exceed 2 MB",
"image": "Image",
"refundCannotBeProcessedPatientAdmitted": "Refund cannot be processed while the patient is admitted",
"cashRefundLimitExceeded": "You can only apply for cash refund if the amount is less than 5000. For more information please visit us.",
"cashRefundInstructions": "Once the request is approved, Please visit HMG Hospital or HMG Primary Care cashier to receive cash. At the cashier counter, you will receive an OTP on your registered mobile number. Provide the OTP to the cashier for validation. Cash will be released only after successful validation.",
"cashRefundApprovalInstructions": "Once the request is approved,\nPlease visit HMG Hospital or HMG Primary Care cashier to receive cash.\nAt the cashier counter, you will receive an OTP on your registered mobile number. Provide the OTP to the cashier for validation. Cash will be released only after successful validation."
}

@ -377,6 +377,7 @@ class AppAssets {
static const String invoiceListIcon = '$svgBasePath/invoice_list_icon.svg';
static const String open_camera = '$svgBasePath/open_camera.svg';
static const String gallery = '$svgBasePath/gallery.svg';
static const String openFileIcon = '$svgBasePath/open_file_icon.svg';
static const String files = '$svgBasePath/files.svg';
static const String online_checkin_icon = '$svgBasePath/online_checkin_icon.svg';
static const String hmg_pharmacy_logo = '$svgBasePath/hmg_pharmacy_logo.svg';

@ -22,6 +22,8 @@ abstract class HabibWalletRepo {
Future<Either<Failure, GenericApiModel<dynamic>>> getLakumAccountInformation({required String identificationNumber});
Future<Either<Failure, GenericApiModel<dynamic>>> getLakumAccountInquiryInformation({required String lakumAccountNumber});
Future<Either<Failure, GenericApiModel<dynamic>>> checkIsPatientAdmittedOrAdmissionRequest({required bool isActiveAppointment});
}
class HabibWalletRepoImp implements HabibWalletRepo {
@ -306,4 +308,40 @@ class HabibWalletRepoImp implements HabibWalletRepo {
return Left(UnknownFailure(e.toString()));
}
}
@override
Future<Either<Failure, GenericApiModel<dynamic>>> checkIsPatientAdmittedOrAdmissionRequest({required bool isActiveAppointment}) async {
Map<String, dynamic> requestBody = {
"IsActiveAppointment": isActiveAppointment,
};
try {
GenericApiModel<dynamic>? apiResponse;
Failure? failure;
await apiClient.post(
CHECK_IF_PATIENT_ADMITTED,
body: requestBody,
onFailure: (error, statusCode, {messageStatus, failureType}) {
failure = failureType;
},
onSuccess: (response, statusCode, {messageStatus, errorMessage}) {
try {
apiResponse = GenericApiModel<dynamic>(
messageStatus: messageStatus,
statusCode: statusCode,
errorMessage: errorMessage,
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()));
}
}
}

@ -12,6 +12,7 @@ import 'package:hmg_patient_app_new/features/refund_request/models/req_models/su
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/models/resp_models/refundable_invoices_response_model.dart';
import 'package:hmg_patient_app_new/features/refund_request/refund_request_repo.dart';
import 'package:hmg_patient_app_new/features/refund_request/refund_request_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/services/error_handler_service.dart';
@ -42,6 +43,8 @@ class HabibWalletViewModel extends ChangeNotifier {
// Wallet Withdrawal Step Management
int currentWithdrawStep = 0;
RefundableAdvancesItem? selectedAdvance;
RefundTabType? selectedWithdrawMethod; // Bank Transfer or Cash
static const double cashRefundMaxAmount = 5000.0; // Maximum amount for cash refund
void nextWithdrawStep() {
if (currentWithdrawStep < 1) {
@ -62,8 +65,16 @@ class HabibWalletViewModel extends ChangeNotifier {
notifyListeners();
}
void setSelectedWithdrawMethod(RefundTabType? method) {
selectedWithdrawMethod = method;
notifyListeners();
}
double get selectedAdvanceBalance => selectedAdvance?.balanceAmount ?? 0.0;
// Check if selected advance balance exceeds cash limit
bool get isCashWithdrawAmountExceeded => selectedAdvanceBalance > cashRefundMaxAmount;
bool isSearchedFileNumberDataShown = false;
int currentIndex = 0;
@ -280,6 +291,7 @@ class HabibWalletViewModel extends ChangeNotifier {
required String patientNationalId,
required int projectId,
required String projectName,
required String refundDestination, // '2' for Bank Transfer, '3' for Cash
Function(SubmitRefundResponseModel)? onSuccess,
Function(String)? onError,
}) async {
@ -316,8 +328,8 @@ class HabibWalletViewModel extends ChangeNotifier {
refundDoctorName: '',
refundClinicId: 0,
refundClinicName: '',
refundDestination: '2',
// only bank transfer
refundDestination: refundDestination,
// Use provided refund destination
refundIban: iban,
refundRemarks: remarks,
refundTotalAmount: selectedAdvanceBalance,
@ -604,4 +616,45 @@ class HabibWalletViewModel extends ChangeNotifier {
},
);
}
// Check Is Patient Admitted Or Admission Request
bool isCheckingPatientAdmitted = false;
Future<void> checkIsPatientAdmittedOrAdmissionRequest({
required bool isActiveAppointment,
Function(dynamic)? onSuccess,
Function(String)? onError,
}) async {
isCheckingPatientAdmitted = true;
notifyListeners();
final result = await habibWalletRepo.checkIsPatientAdmittedOrAdmissionRequest(
isActiveAppointment: isActiveAppointment,
);
result.fold(
(failure) {
isCheckingPatientAdmitted = false;
notifyListeners();
if (onError != null) {
onError(failure.message);
} else {
errorHandlerService.handleError(failure: failure);
}
},
(apiResponse) {
isCheckingPatientAdmitted = false;
notifyListeners();
if (apiResponse.messageStatus == 1) {
if (onSuccess != null) {
onSuccess(apiResponse.data);
}
} else {
if (onError != null) {
onError(apiResponse.errorMessage ?? LocaleKeys.failed.tr());
}
}
},
);
}
}

@ -79,6 +79,9 @@ class SubmitRefundRequestModel {
/// Populated only for wallet withdrawal refunds (refundType == '2').
final List<SubmitRefundAdvanceItem> refundAdvancesList;
/// List of base64 encoded images for refund attachments (max 4 files, each max 2MB)
final List<String> refundAttachmentsBase64List;
const SubmitRefundRequestModel({
required this.projectId,
required this.refundType,
@ -101,6 +104,7 @@ class SubmitRefundRequestModel {
required this.isRefundFastTrack,
required this.refundProceduresList,
this.refundAdvancesList = const [],
this.refundAttachmentsBase64List = const [],
});
/// refundType '1' = invoice refund sends Refund_ProceduresList
@ -122,6 +126,7 @@ class SubmitRefundRequestModel {
"Refund_Remarks": refundRemarks,
"Refund_TotalRefundAmount": refundTotalAmount,
"Refund_IsFastTrack": isRefundFastTrack,
"Refund_AttachmentsBase64List": refundAttachmentsBase64List,
};
if (refundType == '2') {

@ -11,7 +11,7 @@ import 'package:hmg_patient_app_new/features/refund_request/refund_request_repo.
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/services/error_handler_service.dart';
enum RefundTabType { invoices, hmgWallet }
enum RefundTabType { invoices, hmgWallet, cash }
extension RefundTabTypeExtension on RefundTabType {
String toDisplayString() {
@ -20,6 +20,8 @@ extension RefundTabTypeExtension on RefundTabType {
return LocaleKeys.hmgWallet.tr();
case RefundTabType.invoices:
return LocaleKeys.bankTransfer.tr();
case RefundTabType.cash:
return LocaleKeys.cash.tr();
}
}
}
@ -117,6 +119,8 @@ class RefundRequestViewModel extends ChangeNotifier {
invoiceDetails = null;
_ibanError = null;
_descriptionError = null;
refundAttachmentList.clear();
_attachmentError = null;
// Use Future.microtask to avoid calling notifyListeners while tree is locked (e.g. during dispose)
Future.microtask(() {
if (hasListeners) notifyListeners();
@ -305,6 +309,47 @@ class RefundRequestViewModel extends ChangeNotifier {
return isValid;
}
// Attachments
List<String> refundAttachmentList = []; // Stores base64 strings
String? _attachmentError;
static const int maxAttachments = 4;
static const int maxFileSizeBytes = 2 * 1024 * 1024; // 2MB
static const double cashRefundMaxAmount = 5000.0; // Maximum amount for cash refund
String? get attachmentError => _attachmentError;
// Check if total refundable amount exceeds cash limit
bool get isCashRefundAmountExceeded => totalRefundableAmount > cashRefundMaxAmount;
void addRefundAttachment(String base64String) {
if (refundAttachmentList.length >= maxAttachments) {
_attachmentError = LocaleKeys.maximumFilesReached.tr();
notifyListeners();
return;
}
refundAttachmentList.add(base64String);
_attachmentError = null;
notifyListeners();
}
void removeRefundAttachment(int index) {
if (index >= 0 && index < refundAttachmentList.length) {
refundAttachmentList.removeAt(index);
_attachmentError = null;
notifyListeners();
}
}
void clearAttachmentError() {
_attachmentError = null;
notifyListeners();
}
void setAttachmentError(String error) {
_attachmentError = error;
notifyListeners();
}
// Payment Method
RefundTabType? selectedPaymentMethod;

@ -1875,6 +1875,7 @@ abstract class LocaleKeys {
static const cannotOpenThisFile = 'cannotOpenThisFile';
static const thisInvoiceIsNotEligibleForRefund = 'thisInvoiceIsNotEligibleForRefund';
static const refundDetails = 'refundDetails';
static const refund = 'refund';
static const requestNo = 'requestNo';
static const referenceNo = 'referenceNo';
static const advanceNumber = 'advanceNumber';
@ -1883,6 +1884,12 @@ abstract class LocaleKeys {
static const selectAdvance = 'selectAdvance';
static const advanceNo = 'advanceNo';
static const noAdvancesAvailable = 'noAdvancesAvailable';
static const refund = 'refund';
static const maximumFilesReached = 'maximumFilesReached';
static const fileSizeTooLarge = 'fileSizeTooLarge';
static const image = 'image';
static const refundCannotBeProcessedPatientAdmitted = 'refundCannotBeProcessedPatientAdmitted';
static const cashRefundLimitExceeded = 'cashRefundLimitExceeded';
static const cashRefundInstructions = 'cashRefundInstructions';
static const cashRefundApprovalInstructions = 'cashRefundApprovalInstructions';
}

@ -354,8 +354,7 @@ class FeedbackPage extends StatelessWidget {
context,
true,
(String image, file) {
print(image);
print(file);
// Navigator.pop(context);
contactUsViewModel.addFeedbackAttachment(image);
},

@ -217,6 +217,7 @@ class _OtherDetailsStepState extends State<OtherDetailsStep> {
(String image, File file) {
_addInsuranceDocument(image, file, formManager);
},
allowMultiple: true, // Enable multiple selection
);
},
),

@ -150,7 +150,42 @@ class _HabibWalletState extends State<HabibWalletPage> {
borderColor: Colors.transparent,
padding: EdgeInsets.fromLTRB(4, 0, 12, 0),
fontSize: 14.f,
onPressed: () => Navigator.of(context).push(CustomPageRoute(page: WithdrawRequestCreatePage())),
onPressed: () async {
// Show loader
LoaderBottomSheet.showLoader(loadingText: LocaleKeys.loadingText.tr(context: context));
// Check if patient is admitted
await habibWalletVM.checkIsPatientAdmittedOrAdmissionRequest(
isActiveAppointment: false,
onSuccess: (data) {
LoaderBottomSheet.hideLoader();
// Check if patient is admitted
bool isAdmitted = data['isAdmitted'] ?? false;
if (isAdmitted) {
// Show error message if patient is admitted
showCommonBottomSheetWithoutHeight(
context,
child: Utils.getErrorWidget(
loadingText: LocaleKeys.refundCannotBeProcessedPatientAdmitted.tr(context: context),
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
} else {
// Navigate to withdraw page if not admitted
Navigator.of(context).push(CustomPageRoute(page: WithdrawRequestCreatePage()));
}
},
onError: (error) {
LoaderBottomSheet.hideLoader();
// Navigate to withdraw page even on error (messageStatus == 2)
Navigator.of(context).push(CustomPageRoute(page: WithdrawRequestCreatePage()));
},
);
},
),
),
],
@ -307,5 +342,3 @@ class _HabibWalletState extends State<HabibWalletPage> {
);
}
}

@ -12,6 +12,7 @@ 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/features/refund_request/models/resp_models/refundable_invoices_response_model.dart';
import 'package:hmg_patient_app_new/features/refund_request/refund_request_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/presentation/habib_wallet/widgets/select_hospital_bottom_sheet.dart';
import 'package:hmg_patient_app_new/presentation/refund_request/refund_history_screen.dart';
@ -21,6 +22,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/input_widget.dart';
import 'package:hmg_patient_app_new/widgets/loader/bottomsheet_loader.dart';
import 'package:hmg_patient_app_new/widgets/radio_list_tile_widget.dart';
import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart';
import 'package:hmg_patient_app_new/widgets/stepper/stepper_widget.dart';
import 'package:provider/provider.dart';
@ -225,14 +227,74 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
}
}
// Step 2 Bank Details
// Step 2 Refund Method Selection
bool get _isFormValid {
final vm = _habibWalletViewModel;
return vm.selectedHospital != null &&
!vm.isAdvancesLoading &&
_bankDetailsController.text.trim().isNotEmpty &&
_descriptionController.text.trim().isNotEmpty;
Widget _buildRefundMethodSelection(HabibWalletViewModel vm) {
final paymentOptions = [
RefundTabType.invoices, // Bank Transfer
RefundTabType.cash, // Cash
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.selectRefundMethod.tr(context: context).toText20(isBold: true),
SizedBox(height: 24.h),
Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.h,
hasShadow: false,
),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: paymentOptions.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final type = paymentOptions[index];
return Column(
children: [
RadioListTileWidget<RefundTabType>(
value: type,
groupValue: vm.selectedWithdrawMethod,
title: type.toDisplayString(),
onChanged: (value) {
if (value != null) vm.setSelectedWithdrawMethod(value);
},
),
if (index != paymentOptions.length - 1)
Divider(color: AppColors.bottomNAVBorder, height: 1, thickness: 1).paddingSymmetrical(16.w, 0.w),
],
);
},
),
),
if (vm.selectedWithdrawMethod == RefundTabType.cash && vm.isCashWithdrawAmountExceeded) ...[
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.all(16.h),
decoration: BoxDecoration(
color: AppColors.warningColorYellow.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: AppColors.warningColorYellow, width: 1),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: AppColors.warningColorYellow, size: 20.h),
SizedBox(width: 12.w),
Expanded(
child: LocaleKeys.cashRefundLimitExceeded.tr(context: context).toText14(
color: AppColors.textColor,
),
),
],
),
),
],
],
);
}
// Bottom bar
@ -245,9 +307,18 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
// Step 1 validation: advance selected
final isStep1Valid = vm.selectedAdvance != null;
// Step 2 validation: bank details filled
final isStep2Valid = _bankDetailsController.text.trim().isNotEmpty &&
_descriptionController.text.trim().isNotEmpty;
// Step 2 validation:
// - Refund method must be selected
// - If Bank Transfer selected, IBAN and description required
// - If Cash selected, amount must be <= 5000 SAR
final isBankTransfer = vm.selectedWithdrawMethod == RefundTabType.invoices;
final isCash = vm.selectedWithdrawMethod == RefundTabType.cash;
final isStep2Valid = vm.selectedWithdrawMethod != null &&
(isCash
? !vm.isCashWithdrawAmountExceeded // Cash: check amount limit
: (isBankTransfer &&
_bankDetailsController.text.trim().isNotEmpty &&
_descriptionController.text.trim().isNotEmpty)); // Bank: check fields
return Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
@ -280,9 +351,7 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
),
],
CustomButton(
text: isStep2
? LocaleKeys.submitRequest.tr(context: context)
: LocaleKeys.next.tr(context: context),
text: isStep2 ? LocaleKeys.submitRequest.tr(context: context) : LocaleKeys.next.tr(context: context),
isDisabled: isStep2 ? !isStep2Valid : !isStep1Valid,
onPressed: () {
if (!isStep2) {
@ -291,14 +360,16 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
vm.nextWithdrawStep();
}
} else {
// Submit
final isFormValid = vm.validateWithdrawForm(
iban: _bankDetailsController.text,
description: _descriptionController.text,
);
if (isFormValid) {
_handleWalletWithdrawalSubmit(vm);
// Submit - only validate bank details if Bank Transfer is selected
final isBankTransfer = vm.selectedWithdrawMethod == RefundTabType.invoices;
if (isBankTransfer) {
final isFormValid = vm.validateWithdrawForm(
iban: _bankDetailsController.text,
description: _descriptionController.text,
);
if (!isFormValid) return;
}
_handleWalletWithdrawalSubmit(vm);
}
},
).paddingSymmetrical(24.h, 24.h),
@ -311,8 +382,7 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
Widget _buildProjectSelectionAndBalance(HabibWalletViewModel habibWalletViewModel) {
final isArabic = _appState.isArabic();
final showAmount = habibWalletViewModel.currentWithdrawStep == 1 &&
habibWalletViewModel.selectedAdvance != null;
final showAmount = habibWalletViewModel.currentWithdrawStep == 1 && habibWalletViewModel.selectedAdvance != null;
return Container(
padding: EdgeInsets.all(16.h),
@ -472,6 +542,11 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
LoaderBottomSheet.showLoader();
final user = _appState.getAuthenticatedUser();
final hospital = vm.selectedHospital;
// Determine refund destination: '2' = Bank Transfer, '3' = Cash
final isCash = vm.selectedWithdrawMethod == RefundTabType.cash;
final refundDestination = isCash ? '3' : '2';
await vm.submitWalletWithdrawalRequest(
iban: _bankDetailsController.text.trim(),
remarks: _descriptionController.text.trim(),
@ -481,9 +556,10 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
patientNationalId: user?.patientIdentificationNo?.toString() ?? '',
projectId: hospital?.mainProjectID ?? 0,
projectName: hospital?.name ?? '',
refundDestination: refundDestination,
onSuccess: (response) {
LoaderBottomSheet.hideLoader();
_showWithdrawalSuccessBottomSheet(response.refNo ?? '');
_showWithdrawalSuccessBottomSheet(response.refNo ?? '', refundDestination);
},
onError: (err) {
LoaderBottomSheet.hideLoader();
@ -502,7 +578,10 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
);
}
void _showWithdrawalSuccessBottomSheet(String refNo) {
void _showWithdrawalSuccessBottomSheet(String refNo, String refundDestination) {
// refundDestination: '2' = Bank Transfer, '3' = Cash
final isCashRefund = refundDestination == '3';
showCommonBottomSheetWithoutHeight(
context,
child: Padding(
@ -521,7 +600,11 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
SizedBox(width: 6.w),
refNo.toText18(isBold: true, color: AppColors.textColor, isEnglishOnly: true),
],
).paddingSymmetrical(24.w, 0),
).paddingSymmetrical(16.w, 0),
],
if (isCashRefund) ...[
SizedBox(height: 16.h),
LocaleKeys.cashRefundApprovalInstructions.tr(context: context).toText14(color: AppColors.textColor).paddingSymmetrical(16.w, 0),
],
SizedBox(height: 24.h),
Row(
@ -627,7 +710,16 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
key: const ValueKey('step2'),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: _buildBankInputDetails(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRefundMethodSelection(habibWalletViewModel),
if (habibWalletViewModel.selectedWithdrawMethod == RefundTabType.invoices) ...[
SizedBox(height: 16.h),
_buildBankInputDetails(),
],
],
),
),
),
),
@ -644,4 +736,3 @@ class _WithdrawRequestCreatePageState extends State<WithdrawRequestCreatePage> {
);
}
}

@ -12,7 +12,7 @@ import 'package:hmg_patient_app_new/extensions/widget_extensions.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_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/presentation/refund_request/refund_request_detail_screen.dart';
import 'package:hmg_patient_app_new/presentation/refund_request/refund_request_detail_screen.dart' show RefundRequestDetailScreen;
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';

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
@ -20,6 +22,7 @@ import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart';
import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart';
import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart';
import 'package: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/radio_list_tile_widget.dart';
@ -240,6 +243,7 @@ class _RefundRequestCreatePageState extends State<RefundRequestCreatePage> {
final paymentOptions = [
RefundTabType.hmgWallet,
RefundTabType.invoices,
RefundTabType.cash,
];
return Column(
@ -277,14 +281,191 @@ class _RefundRequestCreatePageState extends State<RefundRequestCreatePage> {
},
),
),
if (vm.selectedPaymentMethod == RefundTabType.cash && vm.isCashRefundAmountExceeded) ...[
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.all(16.h),
decoration: BoxDecoration(
color: AppColors.warningColorYellow.withOpacity(0.1),
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: AppColors.warningColorYellow, width: 1),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: AppColors.warningColorYellow, size: 20.h),
SizedBox(width: 12.w),
Expanded(
child: LocaleKeys.cashRefundLimitExceeded.tr(context: context).toText14(
color: AppColors.textColor,
),
),
],
),
),
],
if (vm.selectedPaymentMethod == RefundTabType.invoices) ...[
SizedBox(height: 16.h),
_buildBankInputDetails(vm),
SizedBox(height: 16.h),
_buildImageUploadSection(vm),
],
],
);
}
Widget _buildImageUploadSection(RefundRequestViewModel vm) {
return Consumer<RefundRequestViewModel>(
builder: (context, refundVM, _) {
final hasError = refundVM.attachmentError != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Upload button
InkWell(
onTap: () {
if (refundVM.refundAttachmentList.length >= 4) {
refundVM.setAttachmentError(LocaleKeys.maximumFilesReached.tr(context: context));
return;
}
ImageOptions.showImageOptionsNew(
context,
true,
(String base64Image, File file) async {
// Validate file size (2MB max)
final fileSize = await file.length();
if (fileSize > RefundRequestViewModel.maxFileSizeBytes) {
refundVM.setAttachmentError(LocaleKeys.fileSizeTooLarge.tr(context: context));
return;
}
// Check max files limit
if (refundVM.refundAttachmentList.length >= RefundRequestViewModel.maxAttachments) {
refundVM.setAttachmentError(LocaleKeys.maximumFilesReached.tr(context: context));
return;
}
// Add attachment
refundVM.addRefundAttachment(base64Image);
},
title: LocaleKeys.selectAttachment.tr(context: context),
allowMultiple: true, // Enable multiple selection
);
},
child: Container(
padding: EdgeInsets.all(16.h),
decoration: BoxDecoration(
color: AppColors.whiteColor,
borderRadius: BorderRadius.circular(24.r),
border: hasError ? Border.all(color: AppColors.primaryRedBorderColor, width: 1) : null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
height: 40.h,
width: 40.h,
padding: EdgeInsets.all(8.h),
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
borderRadius: 12.r,
color: AppColors.greyColor,
),
child: Utils.buildSvgWithAssets(icon: AppAssets.selectImageIcon, applyThemeColor: false),
),
SizedBox(width: 12.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.uploadImage.tr(context: context).toText12(
color: AppColors.inputLabelTextColor,
isBold: true,
),
LocaleKeys.selectImage.tr(context: context).toText14(
color: AppColors.inputLabelTextColor,
isBold: true,
),
],
),
],
),
if (hasError) ...[
SizedBox(height: 8.h),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
refundVM.attachmentError!,
style: TextStyle(
color: AppColors.primaryRedColor,
fontSize: 12.f,
fontWeight: FontWeight.w600,
),
),
],
),
],
],
),
),
Utils.buildSvgWithAssets(icon: AppAssets.arrow_down, width: 25.h, height: 25.h),
],
),
),
),
// Selected images list
if (refundVM.refundAttachmentList.isNotEmpty) ...[
SizedBox(height: 16.h),
Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.r,
hasShadow: false,
),
child: ListView.separated(
padding: EdgeInsets.all(16.h),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: refundVM.refundAttachmentList.length,
separatorBuilder: (context, index) => SizedBox(height: 12.h),
itemBuilder: (BuildContext context, int index) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.attach_file,
color: Color(0xff2B353E),
size: 20.h,
),
SizedBox(width: 8.w),
"${LocaleKeys.image.tr(context: context)} ${index + 1}".toText14(),
],
),
Utils.buildSvgWithAssets(icon: AppAssets.cancel_circle_icon, width: 20.h, height: 20.h).onPress(() {
refundVM.removeRefundAttachment(index);
}),
],
);
},
),
),
],
],
);
},
);
}
// Bank Transfer Input
Widget _buildBankInputDetails(RefundRequestViewModel vm) {
@ -522,7 +703,8 @@ class _RefundRequestCreatePageState extends State<RefundRequestCreatePage> {
isDisabled: isStep2
? (refundRequestViewModel.selectedPaymentMethod == null ||
(refundRequestViewModel.selectedPaymentMethod == RefundTabType.invoices &&
(_ibanController.text.trim().isEmpty || _descriptionController.text.trim().isEmpty)))
(_ibanController.text.trim().isEmpty || _descriptionController.text.trim().isEmpty)) ||
(refundRequestViewModel.selectedPaymentMethod == RefundTabType.cash && refundRequestViewModel.isCashRefundAmountExceeded))
: (refundRequestViewModel.selectedProcedures.isEmpty ||
// For Outpatient/ER: require refund reasons
// For Inpatient/Package: skip refund reason validation
@ -557,12 +739,24 @@ class _RefundRequestCreatePageState extends State<RefundRequestCreatePage> {
LoaderBottomSheet.showLoader();
final isBankTransfer = vm.selectedPaymentMethod == RefundTabType.invoices;
final isCash = vm.selectedPaymentMethod == RefundTabType.cash;
final invoice = widget.invoice;
final userPayload = _appState.getAuthenticatedUser();
final patientId = userPayload!.patientId;
final patientName = "${userPayload.firstName} ${userPayload.lastName}";
final patientMobileNumber = "${userPayload.mobileNumber}";
final patientNationalId = userPayload.patientIdentificationNo.toString();
// Determine refund destination: 1 = HMG Wallet, 2 = Bank Transfer, 3 = Cash
String refundDestination;
if (isCash) {
refundDestination = '3';
} else if (isBankTransfer) {
refundDestination = '2';
} else {
refundDestination = '1'; // HMG Wallet
}
final request = SubmitRefundRequestModel(
projectId: invoice.projectId ?? 0,
refundType: '1',
@ -579,10 +773,11 @@ class _RefundRequestCreatePageState extends State<RefundRequestCreatePage> {
refundDoctorName: invoice.doctorName ?? '',
refundClinicId: invoice.clinicId ?? 0,
refundClinicName: invoice.clinicName ?? '',
refundDestination: isBankTransfer ? '2' : '1',
refundDestination: refundDestination,
refundIban: isBankTransfer ? _ibanController.text.trim() : '',
refundRemarks: isBankTransfer ? _descriptionController.text.trim() : '',
refundTotalAmount: vm.totalRefundableAmount,
refundAttachmentsBase64List: vm.refundAttachmentList,
refundProceduresList: vm.selectedProcedures
.map((p) => SubmitRefundProcedureItem(
lineItemNo: p.apiLineItemNo ?? 0,
@ -598,7 +793,7 @@ class _RefundRequestCreatePageState extends State<RefundRequestCreatePage> {
request: request,
onSuccess: (response) {
LoaderBottomSheet.hideLoader();
_showSuccessBottomSheet(response.refNo ?? '');
_showSuccessBottomSheet(response.refNo ?? '', refundDestination);
},
onError: (err) {
LoaderBottomSheet.hideLoader();
@ -617,7 +812,10 @@ class _RefundRequestCreatePageState extends State<RefundRequestCreatePage> {
);
}
void _showSuccessBottomSheet(String refNo) {
void _showSuccessBottomSheet(String refNo, String refundDestination) {
// refundDestination: '1' = HMG Wallet, '2' = Bank Transfer, '3' = Cash
final isCashRefund = refundDestination == '3';
showCommonBottomSheetWithoutHeight(
context,
child: Padding(
@ -638,6 +836,10 @@ class _RefundRequestCreatePageState extends State<RefundRequestCreatePage> {
],
).paddingSymmetrical(24.w, 0),
],
if (isCashRefund) ...[
SizedBox(height: 16.h),
LocaleKeys.cashRefundApprovalInstructions.tr(context: context).toText14(color: AppColors.textColor),
],
SizedBox(height: 24.h),
Row(
children: [

@ -10,7 +10,9 @@ import 'package:hmg_patient_app_new/features/refund_request/models/resp_models/r
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart';
import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart';
import 'package:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart';
import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart';
class RefundRequestDetailScreen extends StatelessWidget {
final RefundInvoiceModel invoice;
@ -240,6 +242,8 @@ class RefundRequestDetailScreen extends StatelessWidget {
);
}
Widget _buildProcedureRow(BuildContext context, InvoiceRefundProcedure procedure) {
final isApproved = procedure.isApproved ?? false;

@ -1,4 +1,5 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
import 'package:hmg_patient_app_new/core/app_state.dart';
import 'package:hmg_patient_app_new/core/dependencies.dart';
@ -8,7 +9,6 @@ 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/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:flutter/material.dart';
class AttachmentOptions extends StatelessWidget {
final Future<void> Function() onCameraTap;
@ -16,13 +16,8 @@ class AttachmentOptions extends StatelessWidget {
final VoidCallback onFilesTap;
final bool showFilesOption;
const AttachmentOptions({
Key? key,
required this.onCameraTap,
required this.onGalleryTap,
required this.onFilesTap,
this.showFilesOption = true
}) : super(key: key);
const AttachmentOptions({Key? key, required this.onCameraTap, required this.onGalleryTap, required this.onFilesTap, this.showFilesOption = true})
: super(key: key);
@override
Widget build(BuildContext context) {
@ -52,7 +47,7 @@ class AttachmentOptions extends StatelessWidget {
if (showFilesOption)
checkInOptionCard(
appState,
AppAssets.file_icon,
AppAssets.openFileIcon,
LocaleKeys.openFiles.tr(context: context),
LocaleKeys.selectDocumentFiles.tr(context: context),
).onPress(() async {

@ -22,6 +22,7 @@ class ImageOptions {
bool showFilesOption,
Function(String, File) image, {
String? title,
bool allowMultiple = false, // Allow multiple file/image selection
Future<bool> Function()? checkCameraPermission,
Future<bool> Function()? checkGalleryPermission,
}) {
@ -51,7 +52,11 @@ class ImageOptions {
String fileName = _image.path;
var bytes = File(fileName).readAsBytesSync();
String base64Encode = base64.encode(bytes);
image(base64Encode, _image);
// Convert to data URI format
String mimeType = _getMimeType(fileName);
String dataUri = 'data:$mimeType;base64,$base64Encode';
image(dataUri, _image);
}
},
onGalleryTap: () async {
@ -66,21 +71,46 @@ class ImageOptions {
Navigator.pop(context); // Close bottom sheet
if (Platform.isAndroid) {
galleryImageAndroid(image);
galleryImageAndroid(image, allowMultiple: allowMultiple);
} else {
XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery, imageQuality: 20);
if (pickedFile == null) return;
if (allowMultiple) {
// iOS/macOS: Pick multiple images
List<XFile> pickedFiles = await picker.pickMultiImage(imageQuality: 20);
if (pickedFiles.isEmpty) return;
File _image = File(pickedFile.path);
String fileName = _image.path;
var bytes = File(fileName).readAsBytesSync();
String base64Encode = base64.encode(bytes);
image(base64Encode, _image);
for (var pickedFile in pickedFiles) {
File _image = File(pickedFile.path);
var bytes = await _image.readAsBytes();
String base64Encode = base64.encode(bytes);
// Convert to data URI format
String mimeType = _getMimeType(pickedFile.path);
String dataUri = 'data:$mimeType;base64,$base64Encode';
image(dataUri, _image);
}
} else {
// Single image selection
XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery, imageQuality: 20);
if (pickedFile == null) return;
File _image = File(pickedFile.path);
String fileName = _image.path;
var bytes = File(fileName).readAsBytesSync();
String base64Encode = base64.encode(bytes);
// Convert to data URI format
String mimeType = _getMimeType(fileName);
String dataUri = 'data:$mimeType;base64,$base64Encode';
image(dataUri, _image);
}
}
},
onFilesTap: () async {
Navigator.pop(context); // Close bottom sheet
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowMultiple: allowMultiple, // Use the parameter
allowedExtensions: [
'jpg',
'jpeg',
@ -95,8 +125,22 @@ class ImageOptions {
'zip',
],
);
List<File> files = result!.paths.map((path) => File(path!)).toList();
image(result.files.first.path.toString(), files.first);
if (result == null || result.files.isEmpty) return;
// Process selected file(s)
for (var fileData in result.files) {
if (fileData.path != null) {
File selectedFile = File(fileData.path!);
var bytes = await selectedFile.readAsBytes();
String base64Encode = base64.encode(bytes);
// Convert to data URI format
String mimeType = _getMimeType(fileData.path!);
String dataUri = 'data:$mimeType;base64,$base64Encode';
image(dataUri, selectedFile);
}
}
},
),
callBackFunc: () {},
@ -217,23 +261,46 @@ class ImageOptions {
// }
}
void galleryImageAndroid(Function(String, File) image) async {
void galleryImageAndroid(Function(String, File) image, {bool allowMultiple = false}) async {
// Enable Android Photo Picker if available
if (_pickerPlatform is ImagePickerAndroid) {
(_pickerPlatform as ImagePickerAndroid).useAndroidPhotoPicker = true;
}
try {
final XFile? pickedFile =
await _pickerPlatform.getImageFromSource(source: ImageSource.gallery, options: const ImagePickerOptions(imageQuality: 20));
if (allowMultiple) {
// Pick multiple images
final List<XFile> pickedFiles = await picker.pickMultiImage(imageQuality: 20);
if (pickedFile == null) return;
if (pickedFiles.isEmpty) return;
File _image = File(pickedFile.path);
String fileName = _image.path;
var bytes = File(fileName).readAsBytesSync();
String base64Encode = base64.encode(bytes);
image(base64Encode, _image);
for (var pickedFile in pickedFiles) {
File _image = File(pickedFile.path);
var bytes = await _image.readAsBytes();
String base64Encode = base64.encode(bytes);
// Convert to data URI format
String mimeType = _getMimeType(pickedFile.path);
String dataUri = 'data:$mimeType;base64,$base64Encode';
image(dataUri, _image);
}
} else {
// Pick single image
final XFile? pickedFile =
await _pickerPlatform.getImageFromSource(source: ImageSource.gallery, options: const ImagePickerOptions(imageQuality: 20));
if (pickedFile == null) return;
File _image = File(pickedFile.path);
String fileName = _image.path;
var bytes = File(fileName).readAsBytesSync();
String base64Encode = base64.encode(bytes);
// Convert to data URI format
String mimeType = _getMimeType(fileName);
String dataUri = 'data:$mimeType;base64,$base64Encode';
image(dataUri, _image);
}
} catch (e) {
print('Error picking image: $e');
}
@ -256,12 +323,55 @@ void cameraImageAndroid(Function(String, File) image) async {
String fileName = _image.path;
var bytes = File(fileName).readAsBytesSync();
String base64Encode = base64.encode(bytes);
image(base64Encode, _image);
// Convert to data URI format
String mimeType = _getMimeType(fileName);
String dataUri = 'data:$mimeType;base64,$base64Encode';
image(dataUri, _image);
} catch (e) {
print('Error taking picture: $e');
}
}
/// Helper function to determine MIME type from file extension
String _getMimeType(String filePath) {
final extension = filePath.split('.').last.toLowerCase();
switch (extension) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
case 'pdf':
return 'application/pdf';
case 'doc':
return 'application/msword';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case 'xls':
return 'application/vnd.ms-excel';
case 'xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case 'ppt':
return 'application/vnd.ms-powerpoint';
case 'pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
case 'txt':
return 'text/plain';
case 'zip':
return 'application/zip';
case 'rar':
return 'application/x-rar-compressed';
default:
return 'application/octet-stream';
}
}
class _BottomSheet extends StatelessWidget {
final List<Widget> children;

Loading…
Cancel
Save