diff --git a/lib/features/authentication/widgets/otp_verification_screen.dart b/lib/features/authentication/widgets/otp_verification_screen.dart index 1ea9be5..abb36a0 100644 --- a/lib/features/authentication/widgets/otp_verification_screen.dart +++ b/lib/features/authentication/widgets/otp_verification_screen.dart @@ -438,12 +438,13 @@ class _OTPVerificationScreenState extends State { void _onOtpChanged(String value) { // Handle clipboard paste or programmatic input - if (value.length > 1) { - String? otp = _extractOtpFromText(value); - if (otp != null) { - autoFillOtp(otp); - return; - } + if (value.length >= 4) { + _onOtpCompleted(value); + // String? otp = _extractOtpFromText(value); + // if (otp != null) { + // autoFillOtp(otp); + // return; + // } } // The OTPWidget will automatically call onDone when complete @@ -622,19 +623,53 @@ class _OTPVerificationScreenState extends State { /// Auto fill OTP into text fields void autoFillOtp(String otp) { + if (!mounted) return; if (otp.length != _otpLength) return; - // Clear any existing text first - _otpController.clear(); - - // Add a small delay to ensure the UI is updated - Future.delayed(const Duration(milliseconds: 50), () { - _otpController.text = otp; - // Move cursor to the end - _otpController.selection = TextSelection.fromPosition( - TextPosition(offset: otp.length), - ); - }); + try { + // Clear any existing text first + _otpController.clear(); + + // Use WidgetsBinding to ensure the widget tree is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + try { + // Set the text first + _otpController.text = otp; + + // Use a longer delay for iOS and add validation + Future.delayed(const Duration(milliseconds: 300), () { + if (!mounted) return; + + try { + // Only attempt to set selection if conditions are met + if (_otpController.text == otp && + _otpController.text.length == _otpLength && + _otpController.text.length <= _otpController.text.length) { + + final newSelection = TextSelection.fromPosition( + TextPosition(offset: _otpController.text.length), + ); + + // Validate selection before setting + if (newSelection.baseOffset <= _otpController.text.length && + newSelection.extentOffset <= _otpController.text.length) { + _otpController.selection = newSelection; + } + } + } catch (selectionError) { + // Silently fail on selection - text is already set correctly + debugPrint('Selection error (non-critical): $selectionError'); + } + }); + } catch (textError) { + debugPrint('Error setting OTP text: $textError'); + } + }); + } catch (e) { + debugPrint('Error in autoFillOtp: $e'); + } } /// Clear OTP fields diff --git a/lib/features/book_appointments/book_appointments_repo.dart b/lib/features/book_appointments/book_appointments_repo.dart index 71023b5..dbb84d7 100644 --- a/lib/features/book_appointments/book_appointments_repo.dart +++ b/lib/features/book_appointments/book_appointments_repo.dart @@ -18,8 +18,7 @@ abstract class BookAppointmentsRepo { Future>> getDoctorProfile(int clinicID, int projectID, int doctorId, {Function(dynamic)? onSuccess, Function(String)? onError}); - Future>> getDoctorFreeSlots(int clinicID, int projectID, int doctorId, bool isBookingForLiveCare, - {Function(dynamic)? onSuccess, Function(String)? onError}); + Future>> getDoctorFreeSlots(int clinicID, int projectID, int doctorId, bool isBookingForLiveCare, {Function(dynamic)? onSuccess, Function(String)? onError}); } class BookAppointmentsRepoImp implements BookAppointmentsRepo { @@ -77,9 +76,9 @@ class BookAppointmentsRepoImp implements BookAppointmentsRepo { @override Future>>> getDoctorsList(int clinicID, int projectID, bool isNearest, int doctorId, String doctorName, - {isContinueDentalPlan = false}) async { + {isContinueDentalPlan = false, Function(dynamic)? onSuccess, Function(String)? onError}) async { Map mapDevice = { - "ClinicID": clinicID, + "ClinicID": (doctorName == "") ? clinicID : 0, "ProjectID": projectID, "DoctorName": doctorName, //!= null ? doctorId : 0, "ContinueDentalPlan": isContinueDentalPlan, @@ -98,6 +97,7 @@ class BookAppointmentsRepoImp implements BookAppointmentsRepo { body: mapDevice, onFailure: (error, statusCode, {messageStatus, failureType}) { failure = failureType; + onError!(error); }, onSuccess: (response, statusCode, {messageStatus, errorMessage}) { try { @@ -208,7 +208,7 @@ class BookAppointmentsRepoImp implements BookAppointmentsRepo { // final freeSlotsList = list.map((item) => DoctorsListResponseModel.fromJson(item as Map)).toList().cast(); - apiResponse = GenericApiModel>( + apiResponse = GenericApiModel( messageStatus: messageStatus, statusCode: statusCode, errorMessage: null, diff --git a/lib/features/book_appointments/book_appointments_view_model.dart b/lib/features/book_appointments/book_appointments_view_model.dart index c4258cd..6f4a85e 100644 --- a/lib/features/book_appointments/book_appointments_view_model.dart +++ b/lib/features/book_appointments/book_appointments_view_model.dart @@ -1,9 +1,13 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/utils/date_util.dart'; +import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_repo.dart'; import 'package:hmg_patient_app_new/features/book_appointments/models/free_slot.dart'; import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/doctor_profile_response_model.dart'; import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/doctors_list_response_model.dart'; import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/get_clinic_list_response_model.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/models/timeslots.dart'; import 'package:hmg_patient_app_new/services/error_handler_service.dart'; class BookAppointmentsViewModel extends ChangeNotifier { @@ -12,6 +16,7 @@ class BookAppointmentsViewModel extends ChangeNotifier { bool isClinicsListLoading = false; bool isDoctorsListLoading = false; bool isDoctorProfileLoading = false; + bool isDoctorSearchByNameStarted = false; List clinicsList = []; List _filteredClinicsList = []; @@ -26,6 +31,14 @@ class BookAppointmentsViewModel extends ChangeNotifier { late DoctorsProfileResponseModel doctorsProfileResponseModel; List slotsList = []; + List docFreeSlots = []; + List dayEvents = []; + List nextDayEvents = []; + + String selectedAppointmentDate = ""; + String selectedAppointmentTime = ""; + + dynamic freeSlotsResponse; BookAppointmentsRepo bookAppointmentsRepo; ErrorHandlerService errorHandlerService; @@ -52,7 +65,6 @@ class BookAppointmentsViewModel extends ChangeNotifier { isDoctorProfileLoading = true; clinicsList.clear(); doctorsList.clear(); - slotsList.clear(); notifyListeners(); } @@ -87,11 +99,22 @@ class BookAppointmentsViewModel extends ChangeNotifier { notifyListeners(); } + setIsDoctorSearchByNameStarted(bool value) { + isDoctorSearchByNameStarted = value; + notifyListeners(); + } + setDoctorsProfile(DoctorsProfileResponseModel profile) { doctorsProfileResponseModel = profile; notifyListeners(); } + setSelectedAppointmentDateTime(String date, String time) { + selectedAppointmentDate = date; + selectedAppointmentTime = time; + notifyListeners(); + } + void onTabChanged(int index) { selectedTabIndex = index; notifyListeners(); @@ -121,10 +144,13 @@ class BookAppointmentsViewModel extends ChangeNotifier { //TODO: Make the API dynamic with parameters for ProjectID, isNearest, languageID, doctorId, doctorName Future getDoctorsList( {int projectID = 0, bool isNearest = false, int doctorId = 0, String doctorName = "", isContinueDentalPlan = false, Function(dynamic)? onSuccess, Function(String)? onError}) async { - final result = await bookAppointmentsRepo.getDoctorsList(selectedClinic.clinicID!, 0, isNearest, doctorId, doctorName); + doctorsList.clear(); + final result = await bookAppointmentsRepo.getDoctorsList(selectedClinic.clinicID ?? 0, projectID, isNearest, doctorId, doctorName); result.fold( - (failure) async => await errorHandlerService.handleError(failure: failure), + (failure) async { + onError!("No doctors found for the search criteria".needTranslation); + }, (apiResponse) { if (apiResponse.messageStatus == 2) { // dialogService.showErrorDialog(message: apiResponse.errorMessage!, onOkPressed: () {}); @@ -161,21 +187,38 @@ class BookAppointmentsViewModel extends ChangeNotifier { ); } + //TODO: Handle the cases for LiveCare Schedule Future getDoctorFreeSlots({bool isBookingForLiveCare = false, Function(dynamic)? onSuccess, Function(String)? onError}) async { - slotsList.clear(); + docFreeSlots.clear(); + DateTime date; + final DateFormat formatter = DateFormat('HH:mm'); + final DateFormat dateFormatter = DateFormat('yyyy-MM-dd'); + Map _eventsParsed; + final result = await bookAppointmentsRepo.getDoctorFreeSlots(selectedDoctor.clinicID ?? 0, selectedDoctor.projectID ?? 0, selectedDoctor.doctorID ?? 0, isBookingForLiveCare, onError: onError); result.fold( - (failure) async {}, + (failure) async { + print(failure); + }, (apiResponse) { if (apiResponse.messageStatus == 2) { onError!(apiResponse.errorMessage ?? "Unknown error occurred"); // dialogService.showErrorDialog(message: apiResponse.errorMessage!, onOkPressed: () {}); } else if (apiResponse.messageStatus == 1) { - - // apiResponse.data.forEach((element) { - // slotsList.add(FreeSlot.fromJson(element)); - // }); + if (apiResponse.data == null || apiResponse.data!.isEmpty) { + onError!("No free slots available".tr()); + return; + } + freeSlotsResponse = apiResponse.data; + apiResponse.data!.forEach((element) { + // date = (isLiveCareSchedule != null && isLiveCareSchedule) + // ? DateUtil.convertStringToDate(element) + // : + date = DateUtil.convertStringToDateSaudiTimezone(element, int.parse(selectedDoctor.projectID.toString())); + slotsList.add(FreeSlot(date, ['slot'])); + docFreeSlots.add(TimeSlot(isoTime: formatter.format(date), start: new DateTime(date.year, date.month, date.day, 0, 0, 0, 0), end: date, vidaDate: element)); + }); notifyListeners(); if (onSuccess != null) { diff --git a/lib/features/book_appointments/models/resp_models/doctor_profile_response_model.dart b/lib/features/book_appointments/models/resp_models/doctor_profile_response_model.dart index 67e6ffa..944e659 100644 --- a/lib/features/book_appointments/models/resp_models/doctor_profile_response_model.dart +++ b/lib/features/book_appointments/models/resp_models/doctor_profile_response_model.dart @@ -33,7 +33,7 @@ class DoctorsProfileResponseModel { List? specialty; num? actualDoctorRate; String? consultationFee; - double? decimalDoctorRate; + num? decimalDoctorRate; String? doctorImageURL; String? doctorMobileNumber; num? doctorRate; diff --git a/lib/features/book_appointments/models/resp_models/doctors_list_response_model.dart b/lib/features/book_appointments/models/resp_models/doctors_list_response_model.dart index 4cd03a3..51a9403 100644 --- a/lib/features/book_appointments/models/resp_models/doctors_list_response_model.dart +++ b/lib/features/book_appointments/models/resp_models/doctors_list_response_model.dart @@ -177,8 +177,8 @@ class DoctorsListResponseModel { regionNameN = json['RegionNameN']; serviceID = json['ServiceID']; setupID = json['SetupID']; - speciality = json['Speciality'].cast(); - specialityN = json['SpecialityN'].cast(); + speciality = json['Speciality'] != null ? json['Speciality'].cast() : []; + // specialityN = json['SpecialityN'].cast(); transactionType = json['TransactionType']; virtualEmploymentType = json['VirtualEmploymentType']; workingHours = json['WorkingHours']; diff --git a/lib/features/book_appointments/models/timeslots.dart b/lib/features/book_appointments/models/timeslots.dart new file mode 100644 index 0000000..a5da681 --- /dev/null +++ b/lib/features/book_appointments/models/timeslots.dart @@ -0,0 +1,8 @@ +class TimeSlot { + String? isoTime; + DateTime? start; + DateTime? end; + String? vidaDate; + + TimeSlot({required this.isoTime, required this.start, required this.end, this.vidaDate}); +} diff --git a/lib/presentation/book_appointment/book_appointment_page.dart b/lib/presentation/book_appointment/book_appointment_page.dart index 6613320..9533d6c 100644 --- a/lib/presentation/book_appointment/book_appointment_page.dart +++ b/lib/presentation/book_appointment/book_appointment_page.dart @@ -138,6 +138,7 @@ class _BookAppointmentPageState extends State { Utils.buildSvgWithAssets(icon: AppAssets.forward_arrow_icon, iconColor: AppColors.textColor, width: 15.h, height: 15.h), ], ).onPress(() { + bookAppointmentsViewModel.setIsDoctorSearchByNameStarted(false); Navigator.of(context).push( FadePage( page: SearchDoctorByName(), diff --git a/lib/presentation/book_appointment/doctor_profile_page.dart b/lib/presentation/book_appointment/doctor_profile_page.dart index 0bb1098..adcfce2 100644 --- a/lib/presentation/book_appointment/doctor_profile_page.dart +++ b/lib/presentation/book_appointment/doctor_profile_page.dart @@ -1,19 +1,25 @@ +import 'dart:math'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:hijri_gregorian_calendar/hijri_gregorian_calendar.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'; +import 'package:hmg_patient_app_new/core/utils/date_util.dart'; import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/appointment_calendar.dart'; import 'package:hmg_patient_app_new/presentation/lab/collapsing_list_view.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; import 'package:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart'; import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; +import 'package:hmg_patient_app_new/widgets/loader/bottomsheet_loader.dart'; import 'package:provider/provider.dart'; class DoctorProfilePage extends StatelessWidget { @@ -51,6 +57,7 @@ class DoctorProfilePage extends StatelessWidget { ).circle(100), SizedBox(width: 8.h), Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ ("${bookAppointmentsViewModel.doctorsProfileResponseModel.doctorTitleForProfile} ${bookAppointmentsViewModel.doctorsProfileResponseModel.doctorName}") .toString() @@ -106,8 +113,59 @@ class DoctorProfilePage extends StatelessWidget { ), child: CustomButton( text: "View available appointments".needTranslation, - onPressed: () { - showCommonBottomSheetWithoutHeight(context, title: "".needTranslation, child: Utils.getLoadingWidget(), callBackFunc: () {}, isFullScreen: false); + onPressed: () async { + LoaderBottomSheet.showLoader(); + await bookAppointmentsViewModel.getDoctorFreeSlots( + isBookingForLiveCare: false, + onSuccess: (dynamic respData) async { + LoaderBottomSheet.hideLoader(); + + showCommonBottomSheetWithoutHeight( + title: "Pick a Date", + context, + child: AppointmentCalendar(), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + + // //TODO: Calendar design to be changed as per new design & handle Dubai & KSA both cases + // final DateTimeResult picked = await showHijriGregBottomSheet(context, + // design: Design.v2, + // isShowTimeSlots: true, + // height: 750.h, + // dateTimeSlots: bookAppointmentsViewModel.freeSlotsResponse, + // showCalendarToggle: false, + // switcherIcon: Utils.buildSvgWithAssets(icon: AppAssets.language, width: 24.h, height: 24.h), + // language: appState.getLanguageCode()!, + // initialDate: DateUtil.convertStringToDate(bookAppointmentsViewModel.freeSlotsResponse.first), + // okWidget: Padding(padding: EdgeInsets.only(right: 8.h), child: Utils.buildSvgWithAssets(icon: AppAssets.confirm, width: 24.h, height: 24.h)), + // cancelWidget: Padding(padding: EdgeInsets.only(right: 8.h), child: Utils.buildSvgWithAssets(icon: AppAssets.cancel, iconColor: Colors.white, width: 24.h, height: 24.h)), + // onCalendarTypeChanged: (bool value) { + // // isGregorian = true; + // }); + // if (picked != null) { + // print("Selected Date & Time:"); + // print(picked.date.toIso8601String()); + // String formattedTime = '${picked.time.hour.toString().padLeft(2, '0')}:${picked.time.minute.toString().padLeft(2, '0')}'; + // print(formattedTime); + // + // bookAppointmentsViewModel.setSelectedAppointmentDateTime(picked.date.toIso8601String().split("T")[0], formattedTime); + // } else { + // print("User cancelled the picker"); + // return; + // } + }, + onError: (err) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }); }, backgroundColor: AppColors.primaryRedColor, borderColor: AppColors.primaryRedColor, diff --git a/lib/presentation/book_appointment/search_doctor_by_name.dart b/lib/presentation/book_appointment/search_doctor_by_name.dart index 6180932..6692e8e 100644 --- a/lib/presentation/book_appointment/search_doctor_by_name.dart +++ b/lib/presentation/book_appointment/search_doctor_by_name.dart @@ -1,15 +1,27 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.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'; import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; +import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/presentation/book_appointment/doctor_profile_page.dart'; +import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/doctor_card.dart'; import 'package:hmg_patient_app_new/presentation/lab/collapsing_list_view.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; +import 'package:hmg_patient_app_new/widgets/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/transitions/fade_page.dart'; +import 'package:provider/provider.dart'; + +import '../../features/book_appointments/models/resp_models/doctors_list_response_model.dart'; class SearchDoctorByName extends StatefulWidget { const SearchDoctorByName({super.key}); @@ -28,48 +40,170 @@ class _SearchDoctorByNameState extends State { @override Widget build(BuildContext context) { + bookAppointmentsViewModel = Provider.of(context, listen: false); + appState = getIt.get(); return Scaffold( backgroundColor: AppColors.bgScaffoldColor, - body: CollapsingListView( - title: "Choose Doctor".needTranslation, - child: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 24.h), - child: Column( - children: [ - SizedBox(height: 16.h), - TextInputWidget( - labelText: LocaleKeys.search.tr(context: context), - hintText: LocaleKeys.doctorName.tr(context: context), - controller: searchEditingController, - isEnable: true, - prefix: null, - autoFocus: false, - isBorderAllowed: false, - keyboardType: TextInputType.text, - focusNode: textFocusNode, - suffix: searchEditingController.text.isNotEmpty - ? GestureDetector( - onTap: () { - searchEditingController.clear(); - bookAppointmentsViewModel.filterClinics(""); - textFocusNode.unfocus(); - }, - child: Utils.buildSvgWithAssets(icon: AppAssets.close_bottom_sheet_icon, width: 20.h, height: 20.h, fit: BoxFit.scaleDown), - ) - : null, - onChange: (value) { - bookAppointmentsViewModel.filterClinics(value!); - }, - padding: EdgeInsets.symmetric( - vertical: ResponsiveExtension(10).h, - horizontal: ResponsiveExtension(15).h, + body: Column( + children: [ + Expanded( + child: CollapsingListView( + title: "Choose Doctor".needTranslation, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.h), + child: Column( + children: [ + SizedBox(height: 16.h), + TextInputWidget( + labelText: LocaleKeys.search.tr(context: context), + hintText: LocaleKeys.doctorName.tr(context: context), + controller: searchEditingController, + isEnable: true, + prefix: null, + autoFocus: false, + isBorderAllowed: false, + keyboardType: TextInputType.text, + focusNode: textFocusNode, + suffix: searchEditingController.text.isNotEmpty + ? GestureDetector( + onTap: () { + searchEditingController.clear(); + // bookAppointmentsViewModel.filterClinics(""); + textFocusNode.unfocus(); + }, + child: Utils.buildSvgWithAssets(icon: AppAssets.close_bottom_sheet_icon, width: 20.h, height: 20.h, fit: BoxFit.scaleDown), + ) + : null, + onChange: (value) { + // bookAppointmentsViewModel.filterClinics(value!); + }, + padding: EdgeInsets.symmetric( + vertical: ResponsiveExtension(10).h, + horizontal: ResponsiveExtension(15).h, + ), + ), + SizedBox(height: 16.h), + Consumer(builder: (context, bookAppointmentsVM, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + bookAppointmentsVM.isDoctorSearchByNameStarted + ? ListView.separated( + padding: EdgeInsets.only(top: 24.h), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: bookAppointmentsVM.isDoctorsListLoading ? 5 : bookAppointmentsVM.doctorsList.length, + itemBuilder: (context, index) { + return bookAppointmentsVM.isDoctorsListLoading + ? DoctorCard( + doctorsListResponseModel: DoctorsListResponseModel(), + isLoading: true, + ) + : AnimationConfiguration.staggeredList( + position: index, + duration: const Duration(milliseconds: 500), + child: SlideAnimation( + verticalOffset: 100.0, + child: FadeInAnimation( + child: AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.h, hasShadow: true), + child: DoctorCard( + doctorsListResponseModel: bookAppointmentsVM.doctorsList[index], + isLoading: false, + ).onPress(() async { + bookAppointmentsVM.setSelectedDoctor(bookAppointmentsVM.doctorsList[index]); + // bookAppointmentsVM.setSelectedDoctor(DoctorsListResponseModel()); + LoaderBottomSheet.showLoader(); + await bookAppointmentsVM.getDoctorProfile(onSuccess: (dynamic respData) { + LoaderBottomSheet.hideLoader(); + Navigator.of(context).push( + FadePage( + page: DoctorProfilePage(), + ), + ); + }, onError: (err) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }); + }), + ), + ), + ), + ); + }, + separatorBuilder: (BuildContext cxt, int index) => SizedBox(height: 16.h), + ) + : SizedBox.shrink(), + SizedBox(height: 24.h), + ], + ); + }), + ], ), ), - ], + ), + ), + ), + Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.h, + hasShadow: true, ), + child: CustomButton( + text: LocaleKeys.search.tr(context: context), + onPressed: () async { + textFocusNode.unfocus(); + if (searchEditingController.text.isNotEmpty) { + bookAppointmentsViewModel.setIsDoctorSearchByNameStarted(true); + bookAppointmentsViewModel.setIsDoctorsListLoading(true); + // LoaderBottomSheet.showLoader(); + await bookAppointmentsViewModel.getDoctorsList( + doctorName: searchEditingController.text, + onSuccess: (dynamic respData) {}, + onError: (err) { + bookAppointmentsViewModel.setIsDoctorSearchByNameStarted(false); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }); + } else { + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: "Please enter doctor name to search"), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } + }, + backgroundColor: AppColors.primaryRedColor, + borderColor: AppColors.primaryRedColor, + textColor: AppColors.whiteColor, + fontSize: 16, + fontWeight: FontWeight.w500, + borderRadius: 12, + padding: EdgeInsets.fromLTRB(10, 0, 10, 0), + height: 50.h, + icon: AppAssets.search_icon, + iconColor: AppColors.whiteColor, + iconSize: 20.h, + ).paddingSymmetrical(24.h, 24.h), ), - ), + ], ), ); } diff --git a/lib/presentation/book_appointment/select_doctor_page.dart b/lib/presentation/book_appointment/select_doctor_page.dart index 0986c29..c1ff44a 100644 --- a/lib/presentation/book_appointment/select_doctor_page.dart +++ b/lib/presentation/book_appointment/select_doctor_page.dart @@ -144,6 +144,7 @@ class _SelectDoctorPageState extends State { }, separatorBuilder: (BuildContext cxt, int index) => SizedBox(height: 16.h), ), + SizedBox(height: 24.h), ], ); }), diff --git a/lib/presentation/book_appointment/widgets/appointment_calendar.dart b/lib/presentation/book_appointment/widgets/appointment_calendar.dart new file mode 100644 index 0000000..3bb3b9f --- /dev/null +++ b/lib/presentation/book_appointment/widgets/appointment_calendar.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/app_export.dart'; +import 'package:hmg_patient_app_new/core/app_state.dart'; +import 'package:hmg_patient_app_new/core/dependencies.dart'; +import 'package:hmg_patient_app_new/core/utils/date_util.dart'; +import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_view_model.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/models/free_slot.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/models/timeslots.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_corner/smooth_corner.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +class AppointmentCalendar extends StatefulWidget { + const AppointmentCalendar({super.key}); + + @override + State createState() => _AppointmentCalendarState(); +} + +class _AppointmentCalendarState extends State { + late CalendarController _calendarController; + + late AppState appState; + late BookAppointmentsViewModel bookAppointmentsViewModel; + + var selectedDate = ""; + var selectedNextDate = ""; + + int selectedButtonIndex = 0; + int selectedNextDayButtonIndex = -1; + + List dayEvents = []; + List nextDayEvents = []; + + late Map _events; + + static String? selectedTime; + + bool isWaitingAppointmentAvailable = false; + final _selectedDay = DateTime.now(); + + @override + void initState() { + scheduleMicrotask(() { + _calendarController = CalendarController(); + _events = { + _selectedDay: ['Event A0'] + }; + _onDaySelected(DateUtil.convertStringToDate(bookAppointmentsViewModel.freeSlotsResponse[0])); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + bookAppointmentsViewModel = Provider.of(context, listen: false); + appState = getIt.get(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 380.h, + child: SfCalendar( + controller: _calendarController, + minDate: DateTime.now(), + showNavigationArrow: true, + headerHeight: 60.h, + headerStyle: CalendarHeaderStyle( + backgroundColor: AppColors.scaffoldBgColor, + textAlign: TextAlign.start, + textStyle: TextStyle(fontSize: 18.fSize, fontWeight: FontWeight.w600, letterSpacing: -0.46, color: AppColors.primaryRedColor, fontFamily: "Poppins"), + ), + viewHeaderStyle: ViewHeaderStyle( + backgroundColor: AppColors.scaffoldBgColor, + dayTextStyle: TextStyle(fontSize: 14.fSize, fontWeight: FontWeight.w600, letterSpacing: -0.46, color: AppColors.textColor), + ), + view: CalendarView.month, + todayHighlightColor: Colors.transparent, + todayTextStyle: TextStyle(color: AppColors.textColor), + selectionDecoration: ShapeDecoration( + color: AppColors.transparent, + shape: SmoothRectangleBorder( + borderRadius: BorderRadius.circular(12 ?? 0), + smoothness: 1, + side: BorderSide(color: AppColors.primaryRedColor, width: 1.5), + ), + ), + cellBorderColor: AppColors.transparent, + dataSource: MeetingDataSource(_getDataSource()), + monthCellBuilder: (context, details) => Padding( + padding: EdgeInsets.all(12.h), + child: details.date.day.toString().toText14( + isCenter: true, + color: details.date == _calendarController.selectedDate ? AppColors.primaryRedColor : AppColors.textColor, + ), + ), + monthViewSettings: MonthViewSettings( + dayFormat: "EEE", + appointmentDisplayMode: MonthAppointmentDisplayMode.indicator, + showTrailingAndLeadingDates: false, + appointmentDisplayCount: 1, + monthCellStyle: MonthCellStyle( + textStyle: TextStyle(fontSize: 19.fSize), + ), + ), + onTap: (CalendarTapDetails details) { + _calendarController.selectedDate = details.date; + _onDaySelected(details.date!); + }, + ), + ), + //TODO: Add Next Day Span here + Container( + height: 40, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: dayEvents.length, + itemBuilder: (context, index) { + return Container( + margin: EdgeInsets.only(right: (index == dayEvents.length - 1) ? 16 : 5.0, left: index == 0 ? 16 : 5), + child: AppCustomChipWidget( + labelText: dayEvents[index].isoTime, + backgroundColor: AppColors.whiteColor, + ), + // index == selectedButtonIndex ? getSelectedButton(index) : getNormalButton(index), + ); + }, + ), + ), + ], + ); + } + + List _getDataSource() { + final List meetings = []; + for (var slot in bookAppointmentsViewModel.freeSlotsResponse) { + final startTime = DateUtil.convertStringToDate(slot); + + final endTime = startTime.add(const Duration(minutes: 15)); + + meetings.add(Meeting( + "Available", // Or leave empty with "" + startTime, + endTime, + AppColors.primaryRedColor, + false, + "" // Optional notes + )); + } + return meetings; + } + + // TODO: + openTimeSlotsPickerForDate(DateTime dateStart, List freeSlots) { + dayEvents.clear(); + DateTime dateStartObj = new DateTime(dateStart.year, dateStart.month, dateStart.day, 0, 0, 0, 0, 0); + if (isWaitingAppointmentAvailable && DateUtils.isSameDay(dateStart, DateTime.now())) { + dayEvents.add(TimeSlot(isoTime: "Waiting Appointment", start: DateTime.now(), end: DateTime.now(), vidaDate: "")); + } + freeSlots.forEach((v) { + if (v.start == dateStartObj) dayEvents.add(v); + }); + selectedButtonIndex = 0; + List> timeList = []; + for (var i = 0; i < dayEvents.length; i++) { + Map timeSlot = {"isoTime": dayEvents[i].isoTime, "start": dayEvents[i].start.toString(), "end": dayEvents[i].end.toString(), "vidaDate": dayEvents[i].vidaDate}; + timeList.add(timeSlot); + } + selectedTime = dayEvents[selectedButtonIndex].isoTime; + } + + void _onDaySelected(DateTime day) { + final DateFormat formatter = DateFormat('yyyy-MM-dd'); + setState(() { + selectedDate = DateUtil.getWeekDayMonthDayYearDateFormatted(day, "en"); + selectedNextDate = DateUtil.getWeekDayMonthDayYearDateFormatted(day.add(Duration(days: 1)), "en"); + _calendarController.selectedDate = day; + openTimeSlotsPickerForDate(day, bookAppointmentsViewModel.docFreeSlots); + selectedDate = formatter.format(day); + selectedNextDayButtonIndex = -1; + print(_calendarController.selectedDate); + }); + } +} + +class MeetingDataSource extends CalendarDataSource { + MeetingDataSource(List source) { + appointments = source; + } + + @override + DateTime getStartTime(int index) { + return _getMeetingData(index)!.from; + } + + @override + DateTime getEndTime(int index) { + return _getMeetingData(index)!.to; + } + + @override + String getSubject(int index) { + return _getMeetingData(index)!.eventName; + } + + @override + Color getColor(int index) { + return _getMeetingData(index)!.background; + } + + @override + bool isAllDay(int index) { + return _getMeetingData(index)!.isAllDay; + } + + Meeting? _getMeetingData(int index) { + final dynamic meeting = appointments?[index]; + Meeting? meetingData; + if (meeting is Meeting) { + meetingData = meeting; + } + return meetingData; + } +} + +class Meeting { + Meeting(this.eventName, this.from, this.to, this.background, this.isAllDay, this.notes); + + String eventName; + DateTime from; + DateTime to; + Color background; + bool isAllDay; + String notes; +} diff --git a/lib/presentation/habib_wallet/recharge_wallet_page.dart b/lib/presentation/habib_wallet/recharge_wallet_page.dart index ff1a505..02a9426 100644 --- a/lib/presentation/habib_wallet/recharge_wallet_page.dart +++ b/lib/presentation/habib_wallet/recharge_wallet_page.dart @@ -1,4 +1,5 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:family_bottom_sheet/family_bottom_sheet.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'; @@ -122,9 +123,27 @@ class _RechargeWalletPageState extends State { ), Utils.buildSvgWithAssets(icon: AppAssets.arrow_down, width: 25.h, height: 25.h), ], - ).onPress(() { + ).onPress(() async { // showCommonBottomSheetWithoutHeight(context, title: "Select Medical File".needTranslation, child: SelectMedicalFile(), callBackFunc: () {}, isFullScreen: false); - showCommonBottomSheetWithoutHeight(context, title: "Select Medical File".needTranslation, child: const MultiPageBottomSheet(), callBackFunc: () {}, isFullScreen: false); + // showCommonBottomSheetWithoutHeight(context, title: "Select Medical File".needTranslation, child: const MultiPageBottomSheet(), callBackFunc: () {}, isFullScreen: false); + await FamilyModalSheet.show( + context: context, + contentBackgroundColor: AppColors.scaffoldBgColor, + backgroundColor: AppColors.bottomSheetBgColor, + mainContentPadding: EdgeInsets.all(24.h), + isScrollControlled: true, + safeAreaMinimum: EdgeInsets.zero, + useSafeArea: false, + sheetAnimationStyle: AnimationStyle( + duration: Duration(milliseconds: 500), // Custom animation duration + reverseDuration: Duration(milliseconds: 300), // Custom reverse animation duration + ), + builder: (ctx) { + return const MultiPageBottomSheet(); + }, + + // Optional configurations + ); }), SizedBox(height: 16.h), Divider(color: AppColors.borderOnlyColor.withValues(alpha: 0.1), height: 1.h), diff --git a/lib/presentation/habib_wallet/widgets/select-medical_file.dart b/lib/presentation/habib_wallet/widgets/select-medical_file.dart index a70edb3..4724ea1 100644 --- a/lib/presentation/habib_wallet/widgets/select-medical_file.dart +++ b/lib/presentation/habib_wallet/widgets/select-medical_file.dart @@ -38,199 +38,89 @@ class MultiPageBottomSheet extends StatefulWidget { } class _MultiPageBottomSheetState extends State { - final PageController _pageController = PageController(); - int _currentPage = 0; - final int _totalPages = 3; - late AppState appState; - @override - void dispose() { - _pageController.dispose(); - super.dispose(); - } - - void _nextPage() { - if (_currentPage < _totalPages - 1) { - _pageController.animateToPage( - _currentPage + 2, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - } - - void _previousPage() { - if (_currentPage > 0) { - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - } - @override Widget build(BuildContext context) { appState = getIt.get(); - return Container( + return SizedBox( height: MediaQuery.of(context).size.height * 0.38, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(height: 12.h), - Expanded( - child: PageView( - controller: _pageController, - onPageChanged: (index) { - setState(() { - _currentPage = index; - }); - }, - children: [ - _buildPage1(), - _buildPage2(), - _buildPage3(), - ], - ), - ), - ], - ), - ); - } - - Widget _buildPage1() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 16.h, - hasShadow: false, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LocaleKeys.myMedicalFile.tr(context: context).toText16(color: AppColors.textColor, weight: FontWeight.w500), - "${LocaleKeys.fileno.tr(context: context)}: ${appState.getAuthenticatedUser()!.patientId}".toText12(color: AppColors.greyTextColor, fontWeight: FontWeight.w500), - ], - ), - Utils.buildSvgWithAssets(icon: AppAssets.forward_chevron_icon, iconColor: AppColors.textColor, width: 15.h, height: 15.h), + "Select Medical File".toText20(weight: FontWeight.w600).expanded, + Utils.buildSvgWithAssets(icon: AppAssets.close_bottom_sheet_icon, iconColor: Color(0xff2B353E)).onPress(() { + Navigator.of(context).pop(); + }), ], - ).paddingAll(16.h), - ).onPress(() { - Navigator.of(context).pop(); - }), - SizedBox(height: 16.h), - Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 16.h, - hasShadow: false, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LocaleKeys.familyTitle.tr(context: context).toText16(color: AppColors.textColor, weight: FontWeight.w500), - "Select a medical file from your family".needTranslation.toText14(color: AppColors.greyTextColor, weight: FontWeight.w500), - ], - ), - Utils.buildSvgWithAssets(icon: AppAssets.forward_chevron_icon, iconColor: AppColors.textColor, width: 15.h, height: 15.h), - ], - ).paddingAll(16.h), - ), - SizedBox(height: 16.h), - Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 16.h, - hasShadow: false, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LocaleKeys.otherAccount.tr(context: context).toText16(color: AppColors.textColor, weight: FontWeight.w500), - "Any active medical file from HMG".toText14(color: AppColors.greyTextColor, weight: FontWeight.w500), - ], - ), - Utils.buildSvgWithAssets(icon: AppAssets.forward_chevron_icon, iconColor: AppColors.textColor, width: 15.h, height: 15.h), - ], - ).paddingAll(16.h), - ).onPress(() { - _pageController.animateToPage( - 2, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }), - ], - ); - } - - Widget _buildPage2() { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Page 2: Settings', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - SwitchListTile( - title: const Text('Enable notifications'), - value: true, - onChanged: (value) {}, - ), - SwitchListTile( - title: const Text('Dark mode'), - value: false, - onChanged: (value) {}, - ), - const ListTile( - leading: Icon(Icons.language), - title: Text('Language'), - subtitle: Text('English'), - trailing: Icon(Icons.arrow_forward_ios), - ), - ], - ), - ); - } - - Widget _buildPage3() { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Page 3: Summary', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - const Card( - child: Padding( - padding: EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Configuration Complete!'), - SizedBox(height: 8), - Text('Your settings have been saved successfully.'), - ], - ), + Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 16.h, + hasShadow: false, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LocaleKeys.myMedicalFile.tr(context: context).toText16(color: AppColors.textColor, weight: FontWeight.w500), + "${LocaleKeys.fileno.tr(context: context)}: ${appState.getAuthenticatedUser()!.patientId}".toText12(color: AppColors.greyTextColor, fontWeight: FontWeight.w500), + ], + ), + Utils.buildSvgWithAssets(icon: AppAssets.forward_chevron_icon, iconColor: AppColors.textColor, width: 15.h, height: 15.h), + ], + ).paddingAll(16.h), + ).onPress(() { + Navigator.of(context).pop(); + }), + SizedBox(height: 16.h), + Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 16.h, + hasShadow: false, ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LocaleKeys.familyTitle.tr(context: context).toText16(color: AppColors.textColor, weight: FontWeight.w500), + "Select a medical file from your family".needTranslation.toText14(color: AppColors.greyTextColor, weight: FontWeight.w500), + ], + ), + Utils.buildSvgWithAssets(icon: AppAssets.forward_chevron_icon, iconColor: AppColors.textColor, width: 15.h, height: 15.h), + ], + ).paddingAll(16.h), ), + SizedBox(height: 16.h), + Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 16.h, + hasShadow: false, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LocaleKeys.otherAccount.tr(context: context).toText16(color: AppColors.textColor, weight: FontWeight.w500), + "Any active medical file from HMG".toText14(color: AppColors.greyTextColor, weight: FontWeight.w500), + ], + ), + Utils.buildSvgWithAssets(icon: AppAssets.forward_chevron_icon, iconColor: AppColors.textColor, width: 15.h, height: 15.h), + ], + ).paddingAll(16.h), + ).onPress(() {}), ], ), ); diff --git a/lib/widgets/input_widget.dart b/lib/widgets/input_widget.dart index 33b8183..8ec59f9 100644 --- a/lib/widgets/input_widget.dart +++ b/lib/widgets/input_widget.dart @@ -233,6 +233,9 @@ class TextInputWidget extends StatelessWidget { autofocus: autoFocus, textInputAction: TextInputAction.done, cursorHeight: isWalletAmountInput! ? 40.h : 18.h, + onTapOutside: (event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, style: TextStyle(fontSize: fontSize!.fSize, height: isWalletAmountInput! ? 1 / 4 : 21 / 14, fontWeight: FontWeight.w500, color: AppColors.textColor, letterSpacing: -0.2), decoration: InputDecoration( isDense: true, diff --git a/pubspec.yaml b/pubspec.yaml index a635cd1..055fb7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,7 +65,7 @@ dependencies: google_api_availability: ^5.0.1 firebase_analytics: ^11.5.1 jiffy: ^6.4.3 - hijri_gregorian_calendar: ^0.1.0 + hijri_gregorian_calendar: ^0.1.1 web: any flutter_staggered_animations: ^1.1.1 @@ -79,6 +79,7 @@ dependencies: path_provider: ^2.0.8 open_filex: ^4.7.0 flutter_swiper_view: ^1.1.8 + family_bottom_sheet: ^0.1.0 dev_dependencies: flutter_test: