From c56aa5d32e8fc4e15010273e99b75972a5e18626 Mon Sep 17 00:00:00 2001 From: FaizHashmi Date: Tue, 27 Oct 2026 10:46:02 +0300 Subject: [PATCH] Bug Fixes --- lib/config/provider_routes.dart | 3 - .../add_new_service_appointment_page.dart | 433 ------------------ .../appoinments/update_appointment_page.dart | 370 +++++++++------ .../add_appointment_service_bottom_sheet.dart | 417 +++++++++++------ 4 files changed, 529 insertions(+), 694 deletions(-) delete mode 100644 lib/views/appoinments/add_new_service_appointment_page.dart diff --git a/lib/config/provider_routes.dart b/lib/config/provider_routes.dart index 8b1b249..85d5778 100644 --- a/lib/config/provider_routes.dart +++ b/lib/config/provider_routes.dart @@ -1,4 +1,3 @@ -import 'package:car_provider_app/views/appoinments/add_new_service_appointment_page.dart'; import 'package:car_provider_app/views/appoinments/appoinment_detail_list_page.dart'; import 'package:car_provider_app/views/appoinments/appointment_page.dart'; import 'package:car_provider_app/views/appoinments/merge_appointment_page.dart'; @@ -20,7 +19,6 @@ import 'package:car_provider_app/views/subscriptions/my_subscritions_page.dart'; import 'package:car_provider_app/views/subscriptions/all_subscriptions_page.dart'; import 'package:mc_common_app/config/routes.dart'; import 'package:flutter/material.dart'; -import 'package:mc_common_app/models/appointments_models/appointment_list_model.dart'; import 'package:mc_common_app/models/provider_branches_models/branch_detail_model.dart'; import 'package:mc_common_app/utils/enums.dart'; import 'package:mc_common_app/views/advertisement/favourite_ads_view.dart'; @@ -44,7 +42,6 @@ class ProviderAppRoutes { AppRoutes.appointment: (context) => AppointmentPage(branch: ModalRoute.of(context)!.settings.arguments as BranchDetailModel), AppRoutes.appointmentDetailList: (context) => AppointmentDetailListPage(), AppRoutes.updateAppointmentPage: (context) => const UpdateAppointmentPage(), - AppRoutes.addServiceInAppointment: (context) => AddNewServiceAppointmentPage(ModalRoute.of(context)!.settings.arguments as AppointmentListModel), AppRoutes.mergeAppointments: (context) => const MergeAppointmentListPage(), AppRoutes.appoinmentSearchFilterPage: (context) => const AppointmentsFilterView(), diff --git a/lib/views/appoinments/add_new_service_appointment_page.dart b/lib/views/appoinments/add_new_service_appointment_page.dart deleted file mode 100644 index c6e8cb0..0000000 --- a/lib/views/appoinments/add_new_service_appointment_page.dart +++ /dev/null @@ -1,433 +0,0 @@ -import 'dart:async'; - -import 'package:car_provider_app/views/dashboard/widget/general_appointment_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:provider/provider.dart'; -import 'package:mc_common_app/classes/consts.dart'; -import 'package:mc_common_app/extensions/int_extensions.dart'; -import 'package:mc_common_app/extensions/string_extensions.dart'; -import 'package:mc_common_app/generated/locale_keys.g.dart'; -import 'package:mc_common_app/models/appointments_models/appointment_list_model.dart'; -import 'package:mc_common_app/models/general_models/generic_resp_model.dart'; -import 'package:mc_common_app/theme/colors.dart'; -import 'package:mc_common_app/utils/navigator.dart'; -import 'package:mc_common_app/utils/enums.dart'; -import 'package:mc_common_app/utils/utils.dart'; -import 'package:mc_common_app/view_models/appointments_view_model.dart'; -import 'package:mc_common_app/view_models/service_view_model.dart'; -import 'package:mc_common_app/widgets/button/show_fill_button.dart'; -import 'package:mc_common_app/widgets/common_widgets/app_bar.dart'; -import 'package:mc_common_app/widgets/dropdown/dropdow_field.dart'; -import 'package:mc_common_app/widgets/extensions/extensions_widget.dart'; -import 'package:mc_common_app/widgets/txt_field.dart'; -import 'package:mc_common_app/models/appointments_models/selected_appointment_service_model.dart'; -import 'package:car_provider_app/views/appoinments/widget/add_appointment_service_bottom_sheet.dart'; - - - - - -class AddNewServiceAppointmentPage extends StatefulWidget { - final AppointmentListModel appointmentListModel; - - const AddNewServiceAppointmentPage(this.appointmentListModel, {Key? key}) - : super(key: key); - - @override - State createState() => - _AddNewServiceAppointmentPageState(); -} - -class _AddNewServiceAppointmentPageState - extends State { - final List appointmentSelectedServices = []; - ServiceVM? serviceVM; - bool isSubmitting = false; - - DropValue? selectedCurrency; - final List currencies = [ - DropValue(1, 'SAR', 'SAR'), - DropValue(2, 'AED', 'AED'), - DropValue(3, 'USD', 'USD'), - ]; - - @override - void initState() { - super.initState(); - scheduleMicrotask(() { - if (!mounted) return; - serviceVM = context.read(); - serviceVM!.fetchBranchCategory( - EasyLocalization.of(context)?.currentLocale?.countryCode ?? 'SA', - ); - }); - } - - Future _openAddServiceSheet({int? editIndex}) async { - if (isSubmitting) return; - final existing = editIndex == null - ? null - : appointmentSelectedServices[editIndex]; - - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - enableDrag: true, - builder: (_) => AddAppointmentServiceBottomSheet( - branchId: widget.appointmentListModel.branchId ?? 0, - initialService: existing, - ), - ); - - if (!mounted || result == null) return; - setState(() { - if (editIndex == null) { - appointmentSelectedServices.add(result); - } else { - appointmentSelectedServices[editIndex] = result; - } - }); - } - - double get _totalItemsPrice => appointmentSelectedServices.fold( - 0, - (sum, selected) => sum + selected.totalPrice, - ); - - List get _allSelectedItemIds => appointmentSelectedServices - .expand((selected) => selected.itemIds) - .where((id) => id > 0) - .toSet() - .toList(); - - Future _submit() async { - if (isSubmitting) return; - final vm = serviceVM ?? context.read(); - Map postParams; - - if (vm.customServiceEnabledStatus) { - if (!vm.isCustomServiceDetailValidated()) return; - postParams = { - 'providerBranchID': widget.appointmentListModel.branchId, - 'appointmentID': widget.appointmentListModel.id, - 'serviceItemsFreeText': [ - { - 'name': vm.customServiceTitle, - 'price': vm.customServicePrice, - 'tax': vm.customerServiceTax, - } - ], - }; - } else { - final ids = _allSelectedItemIds; - if (ids.isEmpty) { - Utils.showToast(LocaleKeys.pleaseSelectItems.tr()); - return; - } - postParams = { - 'providerBranchID': widget.appointmentListModel.branchId, - 'appointmentID': widget.appointmentListModel.id, - 'serviceItemID': ids, - }; - } - - setState(() => isSubmitting = true); - bool loadingVisible = false; - try { - Utils.showLoading(context); - loadingVisible = true; - final GenericRespModel res = - await vm.addNewServiceInAppointment(postParams); - if (!mounted) return; - Utils.hideLoading(context); - loadingVisible = false; - - if (res.messageStatus == 1) { - vm.resetCustomServiceForm(); - await _updateAppointment(context, widget.appointmentListModel.branchId ?? 0); - if (!mounted) return; - Utils.showToast(LocaleKeys.itemsAddedSuccessfully.tr()); - pop(context); - } else { - Utils.showToast(res.message.toString()); - } - } catch (e) { - if (mounted) Utils.showToast(e.toString()); - } finally { - if (mounted && loadingVisible) Utils.hideLoading(context); - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: MyColors.white, - appBar: CustomAppBar(title: LocaleKeys.addService.tr()), - body: Padding( - padding: const EdgeInsets.all(21.0), - child: Stack( - fit: StackFit.expand, - children: [ - SingleChildScrollView( - child: Column( - children: [ - GeneralAppointmentWidget( - appointmentListModel: widget.appointmentListModel, - isNeedTotalPayment: true, - isNeedToShowItems: true, - isNeedToShowToMoreText: false, - onTap: () {}, - ), - 12.height, - Consumer( - builder: (context, ServiceVM serviceVm, _) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - LocaleKeys.customService.tr().toText(fontSize: 18), - 8.width, - Center( - child: Checkbox( - value: serviceVm.customServiceEnabledStatus, - onChanged: (value) { - serviceVm.updateCustomServiceEnabledStatus( - value ?? false, - ); - }, - activeColor: MyColors.darkPrimaryColor, - checkColor: MyColors.white, - fillColor: serviceVm.customServiceEnabledStatus - ? WidgetStateProperty.all(MyColors.darkPrimaryColor) - : WidgetStateProperty.all(MyColors.white), - ), - ), - ], - ), - 5.height, - if (serviceVm.customServiceEnabledStatus) ...[ - LocaleKeys.addServiceDetails.tr().toText(fontSize: 16), - 12.height, - Column( - children: [ - 8.height, - TxtField( - value: serviceVm.customServiceTitle, - errorValue: serviceVm.customServiceTitleError, - hint: LocaleKeys.serviceName.tr(), - onChanged: (v) => serviceVm.updateCustomServiceTitle(v), - ), - 8.height, - Row( - children: [ - Expanded( - flex: 2, - child: TxtField( - value: serviceVm.customServicePrice, - errorValue: serviceVm.customServicePriceError, - keyboardType: TextInputType.number, - numbersOnly: true, - hint: LocaleKeys.price.tr(), - onChanged: (v) => serviceVm.updateCustomServicePrice(v), - ), - ), - 8.width, - Expanded( - flex: 1, - child: DropdownField( - (DropValue value) { - setState(() => selectedCurrency = value); - }, - dropdownValue: selectedCurrency, - list: currencies, - hint: 'Currency', - ), - ), - ], - ), - 8.height, - TxtField( - postfixWidget: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - '%'.toText( - fontWeight: MyFonts.Medium, - fontSize: 15, - color: borderColor, - textAlign: TextAlign.center, - ), - ], - ), - isNeedClickAll: true, - isBackgroundEnabled: true, - hint: LocaleKeys.tax.tr(), - onTap: () {}, - value: serviceVm.customerServiceTax, - errorValue: '', - keyboardType: TextInputType.number, - numbersOnly: true, - onChanged: (v) => serviceVm.updateCustomerServiceTax(v), - ), - ], - ), - ] else ...[ - // READY-MADE SERVICES: select via Bottom Sheet, display cards. - LocaleKeys.selectServiceDetails.tr().toText(fontSize: 16), - 12.height, - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: isSubmitting ? null : () => _openAddServiceSheet(), - icon: const Icon(Icons.add_circle_outline), - label: Text(LocaleKeys.addServices.tr()), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(52), - foregroundColor: MyColors.darkPrimaryColor, - side: const BorderSide(color: MyColors.darkPrimaryColor), - ), - ), - ), - 12.height, - ...appointmentSelectedServices.asMap().entries.map((entry) { - final index = entry.key; - final selected = entry.value; - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - selected.service.value, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - - // Edit - MyAssets.icEdit.buildSvg( - height: 17, - width: 17, - ).onPress(() { - _openAddServiceSheet(editIndex: index); - }), - const SizedBox(width: 10), - // Delete - MyAssets.closeWithOrangeBg.buildSvg( - height: 30, - width: 30, - ).onPress(() { - setState(() { - appointmentSelectedServices.removeAt(index); - }); - }), - ], - ), - Text( - selected.category.value, - style: const TextStyle(color: Colors.grey), - ), - 8.height, - ...selected.items.map( - (item) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - Expanded(child: Text(item.name ?? '')), - Text('${item.price ?? '0'} SAR'), - ], - ), - ), - ), - const Divider(), - Text( - '${selected.totalPrice.toStringAsFixed(2)} SAR', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ], - ), - ), - ); - }), - if (appointmentSelectedServices.isNotEmpty) ...[ - 12.height, - LocaleKeys.totalAdditionalAmount.tr().toText( - fontSize: 14, - isBold: true, - color: MyColors.lightTextColor, - ), - Text( - '${_totalItemsPrice.toStringAsFixed(2)} SAR', - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ], - ).toWhiteContainer(width: double.infinity, allPading: 12); - }, - ), - 85.height, - ], - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Row( - children: [ - Expanded( - child: ShowFillButton( - title: LocaleKeys.cancel.tr(), - maxWidth: double.infinity, - isFilled: false, - txtColor: MyColors.redColor, - borderColor: MyColors.redColor, - onPressed: () { - (serviceVM ?? context.read()) - .resetCustomServiceForm(); - pop(context); - }, - ), - ), - 12.width, - Expanded( - child: ShowFillButton( - title: LocaleKeys.add.tr(), - maxWidth: double.infinity, - onPressed: _submit, - ), - ), - ], - ).toWhiteContainer(width: double.infinity), - ), - ], - ), - ), - ); - } - - Future _updateAppointment(BuildContext context, int branchId) async { - await context.read().applyFilterOnAppointmentsVMForProviders( - branchID: branchId, - appointmentStatusEnum: AppointmentStatusEnum.allAppointments, - isNeedCustomerFilter: true, - ); - } -} diff --git a/lib/views/appoinments/update_appointment_page.dart b/lib/views/appoinments/update_appointment_page.dart index be3edd5..e7d9b2a 100644 --- a/lib/views/appoinments/update_appointment_page.dart +++ b/lib/views/appoinments/update_appointment_page.dart @@ -1,18 +1,20 @@ +import 'package:car_provider_app/views/appoinments/widget/add_appointment_service_bottom_sheet.dart'; import 'package:car_provider_app/views/appoinments/widget/sheets.dart'; import 'package:car_provider_app/views/dashboard/widget/general_appointment_widget.dart'; import 'package:flutter/material.dart'; -import 'package:mc_common_app/classes/app_state.dart'; -import 'package:mc_common_app/config/dependency_injection.dart'; -import 'package:mc_common_app/config/routes.dart'; +import 'package:mc_common_app/classes/consts.dart'; import 'package:mc_common_app/extensions/int_extensions.dart'; +import 'package:mc_common_app/extensions/string_extensions.dart'; import 'package:mc_common_app/generated/locale_keys.g.dart'; import 'package:mc_common_app/models/appointments_models/appointment_list_model.dart'; +import 'package:mc_common_app/models/appointments_models/selected_appointment_service_model.dart'; +import 'package:mc_common_app/models/general_models/generic_resp_model.dart'; import 'package:mc_common_app/theme/colors.dart'; import 'package:mc_common_app/utils/enums.dart'; import 'package:mc_common_app/utils/navigator.dart'; import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/view_models/appointments_view_model.dart'; -import 'package:mc_common_app/widgets/bottom_sheet.dart'; +import 'package:mc_common_app/view_models/service_view_model.dart'; import 'package:mc_common_app/widgets/button/show_fill_button.dart'; import 'package:mc_common_app/widgets/common_widgets/app_bar.dart'; import 'package:mc_common_app/widgets/extensions/extensions_widget.dart'; @@ -29,6 +31,15 @@ class UpdateAppointmentPage extends StatefulWidget { class _UpdateAppointmentPageState extends State { late AppointmentListModel appointmentListModel; + // Ready-made services picked via the bottom sheet, staged locally (not yet submitted + // to the API) -- one sheet open per service, each pop appends one card here. Custom + // services bypass this entirely: the sheet submits those itself and just signals + // "refresh" on success (see _openAddServiceSheet). While this list is non-empty, the + // footer swaps to Add/Cancel for it instead of the normal status-driven buttons + // (Arrived, Pay Now, etc.), since both sets of actions can't compete for the same bar. + final List pendingNewServices = []; + bool isSubmittingNewService = false; + @override Widget build(BuildContext context) { return Scaffold( @@ -38,17 +49,11 @@ class _UpdateAppointmentPageState extends State { height: double.infinity, child: Consumer(builder: (BuildContext context, AppointmentsVM appointmentsVM, Widget? child) { appointmentListModel = appointmentsVM.myFilteredAppointmentsForProvider[appointmentsVM.selectedAppointmentIndex].customerAppointmentList![appointmentsVM.selectedAppointmentSubIndex]; - print('========== SELECTED APPOINTMENT =========='); - print('id: ${appointmentListModel.id}'); - print('servicePaymentStatus: ${appointmentListModel.servicePaymentStatus}'); - print( - 'appointmentPaymentStatusEnum: ' - '${appointmentListModel.appointmentPaymentStatusEnum}', - ); if (appointmentsVM.state == ViewState.busy) { return const Center(child: CircularProgressIndicator()); } else { + final bool hasPendingAdd = pendingNewServices.isNotEmpty; return RefreshIndicator( onRefresh: () async => _updateAppointment(context, appointmentListModel.branchId ?? 0), child: Column( @@ -69,136 +74,121 @@ class _UpdateAppointmentPageState extends State { if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.confirmed || appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.arrived || appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted) ...[ + if (pendingNewServices.isNotEmpty) ...[ + ..._buildPendingCards(), + 12.height, + ], ShowFillButton( title: ("+ ${LocaleKeys.addNewService.tr()}"), txtColor: MyColors.darkPrimaryColor, isFilled: false, - onPressed: () { - navigateWithName( - context, - AppRoutes.addServiceInAppointment, - arguments: appointmentListModel, - ); - }, + onPressed: isSubmittingNewService ? null : () => _openAddServiceSheet(context), ), - ], ], ), ), - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.cancelled) ...[ - Utils.buildStatusContainer(LocaleKeys.appointmentCancelled.tr()).paddingAll(10), - ], - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.booked) + if (hasPendingAdd) ...[ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded(child: showPayNowButton(context, appointmentsVM, color: MyColors.greenColor)), + Expanded( + child: ShowFillButton( + title: LocaleKeys.cancel.tr(), + maxWidth: double.infinity, + isFilled: false, + txtColor: MyColors.redColor, + borderColor: MyColors.redColor, + onPressed: isSubmittingNewService ? null : _cancelPendingNewServices, + ), + ), 12.width, - Expanded(child: showCancelButton(context, appointmentsVM)), - ], - ).paddingAll(21), - - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.confirmed && - (appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payPartial || - appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.defaultStatus)) - Column( - children: [ - Row( - children: [ - Expanded(child: showArrivedButton(context, appointmentsVM)), - 12.width, - Expanded(child: showCancelButton(context, appointmentsVM)), - ], + Expanded( + child: ShowFillButton( + title: LocaleKeys.done.tr(), + maxWidth: double.infinity, + onPressed: isSubmittingNewService ? null : _submitPendingNewServices, + ), ), ], ).paddingAll(21), + ] else ...[ + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.cancelled) ...[ + Utils.buildStatusContainer(LocaleKeys.appointmentCancelled.tr()).paddingAll(10), + ], + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.booked) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: showPayNowButton(context, appointmentsVM, color: MyColors.greenColor)), + 12.width, + Expanded(child: showCancelButton(context, appointmentsVM)), + ], + ).paddingAll(21), - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.arrived && - (appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payPartial || - appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.defaultStatus)) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(child: showPayNowButton(context, appointmentsVM)), - 12.width, - Expanded(child: showPayLaterButton(context, appointmentsVM)), - ], - ).paddingAll(21), + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.confirmed && + (appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payPartial || + appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.defaultStatus)) + Column( + children: [ + Row( + children: [ + Expanded(child: showArrivedButton(context, appointmentsVM)), + 12.width, + Expanded(child: showCancelButton(context, appointmentsVM)), + ], + ), + ], + ).paddingAll(21), - if ((appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.arrived || appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted) && - appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payNow) - Utils.buildStatusContainer(LocaleKeys.waitingPaymentfromtheCustomer.tr()).paddingAll(10), + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.arrived && + (appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payPartial || + appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.defaultStatus)) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: showPayNowButton(context, appointmentsVM)), + 12.width, + Expanded(child: showPayLaterButton(context, appointmentsVM)), + ], + ).paddingAll(21), - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.arrived && - (appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.paid || - appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payLater)) - Column( - children: [ - showWorkStartButton(context, appointmentsVM), - ], - ).paddingAll(21), + if ((appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.arrived || appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted) && + appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payNow) + Utils.buildStatusContainer(LocaleKeys.waitingPaymentfromtheCustomer.tr()).paddingAll(10), - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted && appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payPartial) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(child: showPayNowButton(context, appointmentsVM)), - 12.width, - Expanded(child: showPayLaterButton(context, appointmentsVM)), - ], - ).paddingAll(21), - // "Show Pay Now".toText(), + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.arrived && + (appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.paid || + appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payLater)) + Column( + children: [ + showWorkStartButton(context, appointmentsVM), + ], + ).paddingAll(21), - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted && appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.paid) - Column( - children: [ - showCompleteButton(context, appointmentsVM), - ], - ).paddingAll(21), - // "Show Complete Button".toText(), - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted && appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payLater) - showPayNowButton(context, appointmentsVM).paddingAll(21), + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted && appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payPartial) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: showPayNowButton(context, appointmentsVM)), + 12.width, + Expanded(child: showPayLaterButton(context, appointmentsVM)), + ], + ).paddingAll(21), - if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.visitCompleted) ...[ - Utils.buildStatusContainer(LocaleKeys.appointmentisCompleted.tr()).paddingAll(10), - ], + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted && appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.paid) + Column( + children: [ + showCompleteButton(context, appointmentsVM), + ], + ).paddingAll(21), - //TODO: THIS NEEDS TO BE CHECKED. IMPORTANT - // Padding( - // padding: const EdgeInsets.all(21.0), - // child: Column( - // children: [ - // ShowFillButton( - // title: "Confirm Arrive", - // maxWidth: double.infinity, - // onPressed: () { - // showMyBottomSheet( - // context, - // child: ShowCollectPaymentSheet( - // onClickYes: () {}, - // onClickNo: () {}, - // ), - // ); - // }, - // ), - // 12.height, - // ShowFillButton( - // title: "Cancel Appointment", - // maxWidth: double.infinity, - // isFilled: false, - // txtColor: MyColors.redColor, - // borderColor: MyColors.redColor, - // onPressed: () { - // showMyBottomSheet(context, - // child: CancelAppointmentReasonSheet( - // onCancelClick: (String reason) {}, - // )); - // }, - // ), - // ], - // ), - // ), + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.workStarted && appointmentListModel.appointmentPaymentStatusEnum == AppointmentPaymentStatusEnum.payLater) + showPayNowButton(context, appointmentsVM).paddingAll(21), + + if (appointmentListModel.appointmentStatusEnum == AppointmentStatusEnum.visitCompleted) ...[ + Utils.buildStatusContainer(LocaleKeys.appointmentisCompleted.tr()).paddingAll(10), + ], + ], ], ), ); @@ -207,6 +197,139 @@ class _UpdateAppointmentPageState extends State { ); } + List _buildPendingCards() { + return pendingNewServices.asMap().entries.map((entry) { + final index = entry.key; + final selected = entry.value; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + selected.service.value, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + MyAssets.icEdit.buildSvg(height: 17, width: 17).onPress( + isSubmittingNewService ? () {} : () => _editPendingCard(index), + ), + const SizedBox(width: 10), + MyAssets.closeWithOrangeBg.buildSvg(height: 30, width: 30).onPress( + isSubmittingNewService + ? () {} + : () { + setState(() => pendingNewServices.removeAt(index)); + }, + ), + ], + ), + Text(selected.category.value, style: const TextStyle(color: Colors.grey)), + 8.height, + ...selected.items.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Expanded(child: Text(item.name ?? '')), + Text('${item.price ?? '0'} SAR'), + ], + ), + ), + ), + const Divider(), + Text( + '${selected.totalPrice.toStringAsFixed(2)} SAR', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + ], + ), + ), + ); + }).toList(); + } + + List get _allPendingItemIds => pendingNewServices.expand((selected) => selected.itemIds).where((id) => id > 0).toSet().toList(); + + // The sheet returns either: + // - a SelectedAppointmentServiceModel (ready-made pick -- stage it locally), or + // - `true` (a custom service was already submitted by the sheet itself -- refresh), or + // - null (dismissed without choosing anything -- do nothing). + Future _openAddServiceSheet(BuildContext context, {int? editIndex}) async { + final existing = editIndex == null ? null : pendingNewServices[editIndex]; + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + enableDrag: true, + builder: (_) => AddAppointmentServiceBottomSheet(appointmentListModel: appointmentListModel, initialService: existing), + ); + if (!mounted || result == null) return; + + if (result is SelectedAppointmentServiceModel) { + setState(() { + if (editIndex == null) { + pendingNewServices.add(result); + } else { + pendingNewServices[editIndex] = result; + } + }); + } else if (result == true) { + await _updateAppointment(context, appointmentListModel.branchId ?? 0); + } + } + + void _editPendingCard(int index) { + _openAddServiceSheet(context, editIndex: index); + } + + void _cancelPendingNewServices() { + setState(() => pendingNewServices.clear()); + } + + Future _submitPendingNewServices() async { + if (isSubmittingNewService) return; + final ids = _allPendingItemIds; + if (ids.isEmpty) { + Utils.showToast(LocaleKeys.pleaseSelectItems.tr()); + return; + } + final postParams = { + 'providerBranchID': appointmentListModel.branchId, + 'appointmentID': appointmentListModel.id, + 'serviceItemID': ids, + }; + + setState(() => isSubmittingNewService = true); + bool loadingVisible = false; + try { + Utils.showLoading(context); + loadingVisible = true; + final GenericRespModel res = await context.read().addNewServiceInAppointment(postParams); + if (!mounted) return; + Utils.hideLoading(context); + loadingVisible = false; + + if (res.messageStatus == 1) { + await _updateAppointment(context, appointmentListModel.branchId ?? 0); + if (!mounted) return; + Utils.showToast(LocaleKeys.itemsAddedSuccessfully.tr(), isSuccess: true); + setState(() => pendingNewServices.clear()); + } else { + Utils.showToast(res.message.toString()); + } + } catch (e) { + if (mounted) Utils.showToast(e.toString()); + } finally { + if (mounted && loadingVisible) Utils.hideLoading(context); + if (mounted) setState(() => isSubmittingNewService = false); + } + } + Widget showWorkStartButton(BuildContext context, AppointmentsVM appointmentsVM) { return ShowFillButton( title: LocaleKeys.workStart.tr(), @@ -220,15 +343,6 @@ class _UpdateAppointmentPageState extends State { } Utils.hideLoading(context); - // pop(context); - - // showMyBottomSheet( - // context, - // child: ShowCollectPaymentSheet( - // onClickYes: () {}, - // onClickNo: () {}, - // ), - // ); }, ); } diff --git a/lib/views/appoinments/widget/add_appointment_service_bottom_sheet.dart b/lib/views/appoinments/widget/add_appointment_service_bottom_sheet.dart index 1d9d35d..6a36572 100644 --- a/lib/views/appoinments/widget/add_appointment_service_bottom_sheet.dart +++ b/lib/views/appoinments/widget/add_appointment_service_bottom_sheet.dart @@ -1,54 +1,88 @@ - - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:mc_common_app/classes/consts.dart'; +import 'package:mc_common_app/extensions/int_extensions.dart'; +import 'package:mc_common_app/extensions/string_extensions.dart'; import 'package:mc_common_app/generated/locale_keys.g.dart'; +import 'package:mc_common_app/models/appointments_models/appointment_list_model.dart'; +import 'package:mc_common_app/models/general_models/generic_resp_model.dart'; import 'package:mc_common_app/models/services_models/item_model.dart'; import 'package:mc_common_app/theme/colors.dart'; +import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/view_models/service_view_model.dart'; +import 'package:mc_common_app/widgets/button/show_fill_button.dart'; import 'package:mc_common_app/widgets/dropdown/dropdow_field.dart'; import 'package:mc_common_app/widgets/bottom_sheet.dart'; +import 'package:mc_common_app/widgets/txt_field.dart'; import 'package:car_provider_app/views/appoinments/widget/select_items_sheet.dart'; import 'package:car_provider_app/views/branch_management/schedule/widgets/chips_picker_item.dart'; import 'package:mc_common_app/models/appointments_models/selected_appointment_service_model.dart'; -import 'package:car_provider_app/views/branch_management/schedule/widgets/chips_picker_item.dart'; - +// Two different outcomes on pop, by design: +// - Ready-made mode: "Add" just validates the current category/service/items pick and +// pops with a SelectedAppointmentServiceModel -- nothing is submitted here. The caller +// (update_appointment_page.dart) stages it locally as a card; the sheet gets reopened +// once per service the provider wants to add. +// - Custom-service mode (toggle under the header): "Add" submits that one custom +// service via the API immediately and pops with `true` once it succeeds -- there's no +// local staging for this path, the caller just needs to know to refresh. class AddAppointmentServiceBottomSheet extends StatefulWidget { - final int branchId; + final AppointmentListModel appointmentListModel; final SelectedAppointmentServiceModel? initialService; const AddAppointmentServiceBottomSheet({ super.key, - required this.branchId, + required this.appointmentListModel, this.initialService, }); @override - State createState() => - _AddAppointmentServiceBottomSheetState(); + State createState() => _AddAppointmentServiceBottomSheetState(); } -class _AddAppointmentServiceBottomSheetState - extends State { +class _AddAppointmentServiceBottomSheetState extends State { DropValue? category; DropValue? service; List selectedItems = []; bool isLoadingServices = false; String? loadError; + DropValue? selectedCurrency; + final List currencies = [ + DropValue(1, 'SAR', 'SAR'), + DropValue(2, 'AED', 'AED'), + DropValue(3, 'USD', 'USD'), + ]; + + bool isSubmitting = false; + + // Cached here (rather than calling context.read() directly inside + // dispose()) because by the time dispose() runs the element is deactivated, and + // looking up an ancestor Provider at that point throws "Looking up a deactivated + // widget's ancestor is unsafe". didChangeDependencies() is the framework-recommended + // place to grab a reference that needs to survive into dispose(). + late ServiceVM _serviceVM; + @override void initState() { super.initState(); category = widget.initialService?.category; service = widget.initialService?.service; selectedItems = List.of(widget.initialService?.items ?? []); - if (category != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _loadServices(category!.id); - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().fetchBranchCategory( + EasyLocalization.of(context)?.currentLocale?.countryCode ?? 'SA', + ); + if (category != null) _loadServices(category!.id); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _serviceVM = context.read(); } Future _loadServices(int categoryId) async { @@ -58,9 +92,9 @@ class _AddAppointmentServiceBottomSheetState }); try { await context.read().fetchProviderServices( - widget.branchId.toString(), - categoryId.toString(), - ); + (widget.appointmentListModel.branchId ?? 0).toString(), + categoryId.toString(), + ); } catch (e) { if (mounted) loadError = e.toString(); } finally { @@ -71,11 +105,9 @@ class _AddAppointmentServiceBottomSheetState Future _openItemsSheet() async { if (service == null) return; final serviceId = service!.id; - final pickedItems = selectedItems - .map((item) => PickerItem(id: item.id ?? 0, title: item.name ?? '')) - .toList(); + final pickedItems = selectedItems.map((item) => PickerItem(id: item.id ?? 0, title: item.name ?? '')).toList(); - showMyBottomSheet( + showMyBottomSheet( context, child: SelectItemsSheet( serviceId: serviceId, @@ -88,22 +120,68 @@ class _AddAppointmentServiceBottomSheetState ); } - void _confirm() { + void _confirmReadyMade() { if (category == null || service == null || selectedItems.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(LocaleKeys.pleaseSelectItems.tr())), - ); + Utils.showToast(LocaleKeys.pleaseSelectItems.tr()); return; } Navigator.of(context).pop( - SelectedAppointmentServiceModel( - category: category!, - service: service!, - items: List.of(selectedItems), - ), + SelectedAppointmentServiceModel(category: category!, service: service!, items: List.of(selectedItems)), ); } + Future _submitCustomService() async { + if (isSubmitting) return; + final vm = context.read(); + if (!vm.isCustomServiceDetailValidated()) return; + + final postParams = { + 'providerBranchID': widget.appointmentListModel.branchId, + 'appointmentID': widget.appointmentListModel.id, + 'serviceItemsFreeText': [ + { + 'name': vm.customServiceTitle, + 'price': vm.customServicePrice, + 'tax': vm.customerServiceTax, + } + ], + }; + + setState(() => isSubmitting = true); + bool loadingVisible = false; + try { + Utils.showLoading(context); + loadingVisible = true; + final GenericRespModel res = await vm.addNewServiceInAppointment(postParams); + if (!mounted) return; + Utils.hideLoading(context); + loadingVisible = false; + + if (res.messageStatus == 1) { + vm.resetCustomServiceForm(); + Utils.showToast(LocaleKeys.itemsAddedSuccessfully.tr(), isSuccess: true); + Navigator.of(context).pop(true); + } else { + Utils.showToast(res.message.toString()); + } + } catch (e) { + if (mounted) Utils.showToast(e.toString()); + } finally { + if (mounted && loadingVisible) Utils.hideLoading(context); + if (mounted) setState(() => isSubmitting = false); + } + } + + @override + void dispose() { + // Leaving the sheet without submitting shouldn't leave custom-mode state dangling + // for the next time it's opened (same instance-reuse risk as the long-lived VMs + // elsewhere in this app). Uses the cached _serviceVM, not context.read -- see + // didChangeDependencies() above. + _serviceVM.resetCustomServiceForm(); + super.dispose(); + } + @override Widget build(BuildContext context) { return SizedBox( @@ -112,113 +190,192 @@ class _AddAppointmentServiceBottomSheetState top: false, child: Padding( padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 60, - height: 6, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(20), + child: Consumer( + builder: (context, serviceVm, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 60, + height: 6, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(20), + ), + ), ), - ), - ), - const SizedBox(height: 20), - Text( - LocaleKeys.selectServiceDetails.tr(), - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 20), - Expanded( - child: SingleChildScrollView( - child: Consumer( - builder: (context, serviceVm, _) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DropdownField( - (value) { - setState(() { - category = value; - service = null; - selectedItems.clear(); - }); - _loadServices(value.id); - }, - dropdownValue: category, - list: serviceVm.categoryDropList, - hint: LocaleKeys.selectServiceCategory.tr(), - isSelectAble: !isLoadingServices, - ), - if (category != null) ...[ - const SizedBox(height: 16), - if (isLoadingServices) - const Center(child: CircularProgressIndicator()) - else if (loadError != null) - TextButton( - onPressed: () => _loadServices(category!.id), - child: const Text('Retry loading services'), - ) - else - DropdownField( - (value) { - setState(() { - service = value; - selectedItems.clear(); - }); - }, - dropdownValue: service, - list: serviceVm.servicesDropList, - hint: LocaleKeys.defineServices.tr(), - ), - ], - if (service != null) ...[ - const SizedBox(height: 16), - ChipsPickerItem( - isNeedLabelOnTop: true, - hint: LocaleKeys.selectItems.tr(), - itemsList: selectedItems.map((item) { - return PickerItem( - id: item.id ?? 0, - title: item.name ?? "", - ); - }).toList(), - onClick: () { - _openItemsSheet(); + const SizedBox(height: 20), + Text( + LocaleKeys.selectServiceDetails.tr(), + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: LocaleKeys.lookingToAddCustomService.tr().toText(fontSize: 14, color: MyColors.textColor), + ), + 8.width, + LocaleKeys.customService.tr().toText(fontSize: 14, isBold: true), + Switch( + value: serviceVm.customServiceEnabledStatus, + activeColor: MyColors.darkPrimaryColor, + onChanged: isSubmitting + ? null + : (value) { + serviceVm.updateCustomServiceEnabledStatus(value); }, - ), - ...selectedItems.map( - (item) => ListTile( - contentPadding: EdgeInsets.zero, - title: Text(item.name ?? ''), - trailing: Text('${item.price ?? '0'} SAR'), - ), - ), - ], - ], - ); - }, + ), + ], ), - ), - ), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: isLoadingServices ? null : _confirm, - style: ElevatedButton.styleFrom( - backgroundColor: MyColors.darkPrimaryColor, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(50), + const SizedBox(height: 12), + Expanded( + child: SingleChildScrollView( + child: serviceVm.customServiceEnabledStatus ? _buildCustomServiceForm(serviceVm) : _buildReadyMadeSection(serviceVm), + ), ), - child: Text(LocaleKeys.next.tr()), - ), + SizedBox( + width: double.infinity, + child: ShowFillButton( + title: LocaleKeys.add.tr(), + maxWidth: double.infinity, + onPressed: isSubmitting + ? null + : (serviceVm.customServiceEnabledStatus ? _submitCustomService : _confirmReadyMade), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } + + Widget _buildCustomServiceForm(ServiceVM serviceVm) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LocaleKeys.addServiceDetails.tr().toText(fontSize: 16), + 12.height, + TxtField( + value: serviceVm.customServiceTitle, + errorValue: serviceVm.customServiceTitleError, + hint: LocaleKeys.serviceName.tr(), + onChanged: (v) => serviceVm.updateCustomServiceTitle(v), + ), + 8.height, + Row( + children: [ + Expanded( + flex: 2, + child: TxtField( + value: serviceVm.customServicePrice, + errorValue: serviceVm.customServicePriceError, + keyboardType: TextInputType.number, + numbersOnly: true, + hint: LocaleKeys.price.tr(), + onChanged: (v) => serviceVm.updateCustomServicePrice(v), + ), + ), + 8.width, + Expanded( + flex: 1, + child: DropdownField( + (DropValue value) { + setState(() => selectedCurrency = value); + }, + dropdownValue: selectedCurrency, + list: currencies, + hint: 'Currency', ), + ), + ], + ), + 8.height, + TxtField( + postfixWidget: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + '%'.toText(fontWeight: MyFonts.Medium, fontSize: 15, color: borderColor, textAlign: TextAlign.center), ], ), + isNeedClickAll: true, + isBackgroundEnabled: true, + hint: LocaleKeys.tax.tr(), + onTap: () {}, + value: serviceVm.customerServiceTax, + errorValue: '', + keyboardType: TextInputType.number, + numbersOnly: true, + onChanged: (v) => serviceVm.updateCustomerServiceTax(v), ), - ), + ], + ); + } + + Widget _buildReadyMadeSection(ServiceVM serviceVm) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownField( + (value) { + setState(() { + category = value; + service = null; + selectedItems.clear(); + }); + _loadServices(value.id); + }, + dropdownValue: category, + list: serviceVm.categoryDropList, + hint: LocaleKeys.selectServiceCategory.tr(), + isSelectAble: !isLoadingServices, + ), + if (category != null) ...[ + const SizedBox(height: 16), + if (isLoadingServices) + const Center(child: CircularProgressIndicator()) + else if (loadError != null) + TextButton( + onPressed: () => _loadServices(category!.id), + child: const Text('Retry loading services'), + ) + else + DropdownField( + (value) { + setState(() { + service = value; + selectedItems.clear(); + }); + }, + dropdownValue: service, + list: serviceVm.servicesDropList, + hint: LocaleKeys.defineServices.tr(), + ), + ], + if (service != null) ...[ + const SizedBox(height: 16), + ChipsPickerItem( + isNeedLabelOnTop: true, + hint: LocaleKeys.selectItems.tr(), + itemsList: selectedItems.map((item) => PickerItem(id: item.id ?? 0, title: item.name ?? "")).toList(), + onClick: () { + _openItemsSheet(); + }, + ), + ...selectedItems.map( + (item) => ListTile( + contentPadding: EdgeInsets.zero, + title: Text(item.name ?? ''), + trailing: Text('${item.price ?? '0'} SAR'), + ), + ), + ], + ], ); } }