diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 762c964d..0fb36b85 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,10 +41,6 @@ android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" /> - - - - diff --git a/assets/images/offersanddiscounts/img1.png b/assets/images/offersanddiscounts/img1.png new file mode 100644 index 00000000..e2ac1909 Binary files /dev/null and b/assets/images/offersanddiscounts/img1.png differ diff --git a/assets/images/offersanddiscounts/img2.png b/assets/images/offersanddiscounts/img2.png new file mode 100644 index 00000000..a85f4b8a Binary files /dev/null and b/assets/images/offersanddiscounts/img2.png differ diff --git a/assets/images/offersanddiscounts/img3.png b/assets/images/offersanddiscounts/img3.png new file mode 100644 index 00000000..21d03820 Binary files /dev/null and b/assets/images/offersanddiscounts/img3.png differ diff --git a/assets/images/offersanddiscounts/promo.jpg b/assets/images/offersanddiscounts/promo.jpg new file mode 100644 index 00000000..9275c79c Binary files /dev/null and b/assets/images/offersanddiscounts/promo.jpg differ diff --git a/assets/images/svg/arrow-right-02.svg b/assets/images/svg/arrow-right-02.svg new file mode 100644 index 00000000..1c5ca62a --- /dev/null +++ b/assets/images/svg/arrow-right-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/svg/share.svg b/assets/images/svg/share.svg new file mode 100644 index 00000000..c30b9e0f --- /dev/null +++ b/assets/images/svg/share.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/svg/shoppingcart.svg b/assets/images/svg/shoppingcart.svg new file mode 100644 index 00000000..d1c36860 --- /dev/null +++ b/assets/images/svg/shoppingcart.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 62cdd752..e8409260 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -557,7 +557,7 @@ "remeberthat": "Remember that", "loginToUseService": "You need to login to use this service", "offersAndPromotions": "OFFERS & SPECIAL PROMOTIONS", - "offers": "OFFERS", + "offers": "Offers", "myPrescriptions": "MY PRESCRIPTIONS", "searchAndScanMedication": "SEARCH & SCAN FOR MEDICATION", "shopByBrands": "Shop by Brands", diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index e8e53a9a..8dbc9ce3 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -4,7 +4,7 @@ import 'package:hmg_patient_app_new/core/enums.dart'; class ApiConsts { static const maxSmallScreen = 660; - static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.uat; + static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.preProd; // static String baseUrl = 'https://uat.hmgwebservices.com/'; // HIS API URL UAT @@ -680,6 +680,11 @@ var GET_PATIENT_SICK_LEAVE_STATUS = 'Services/Patients.svc/REST/GetPatientSickLe var GET_SERVICES_PRICE_LIST = 'Services/OUTPs.svc/REST/GetServicesPriceList'; +// Offers and Discounts +//TODO: Need to Be Changes Once Apis Provided By Vendor ---- Aamir +var GET_OFFERS_AND_DISCOUNTS = 'Services/Patients.svc/REST/GetOffersAndDiscounts'; +var GET_OFFERS_AND_DISCOUNTS_HISTORY = 'Services/Patients.svc/REST/GetOffersAndDiscountsHistory'; + var SendSickLeaveEmail = 'Services/Notifications.svc/REST/SendSickLeaveEmail'; var GET_PATIENT_AdVANCE_BALANCE_AMOUNT = 'Services/Patients.svc/REST/GetPatientAdvanceBalanceAmount'; diff --git a/lib/core/app_assets.dart b/lib/core/app_assets.dart index 57f91017..d6e041da 100644 --- a/lib/core/app_assets.dart +++ b/lib/core/app_assets.dart @@ -236,6 +236,9 @@ class AppAssets { static const String h_calc_selected = '$svgBasePath/h_calc_selected.svg'; static const String weatherBottom = '$svgBasePath/weather_bottom.svg'; static const String weatherBottomFill = '$svgBasePath/weather_bottom_fill.svg'; + static const String shoppingCart = '$svgBasePath/shoppingcart.svg'; + static const String share = '$svgBasePath/share.svg'; + static const String nextSwiper = '$svgBasePath/arrow-right-02.svg'; static const String height = '$svgBasePath/height.svg'; static const String weight = '$svgBasePath/weight.svg'; diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart index 5eb7a05d..9a4826b0 100644 --- a/lib/core/app_state.dart +++ b/lib/core/app_state.dart @@ -25,6 +25,8 @@ class AppState { _loadProfileImageFromCache(); } + bool isEnabledOffersAndDiscountsCarousel = false; + double userLat = 0.0; set setUserLat(v) => userLat = v; diff --git a/lib/core/dependencies.dart b/lib/core/dependencies.dart index 0c014232..a45e9e6d 100644 --- a/lib/core/dependencies.dart +++ b/lib/core/dependencies.dart @@ -46,11 +46,14 @@ import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_repo.dart'; import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_view_model.dart'; import 'package:hmg_patient_app_new/features/notifications/notifications_repo.dart'; import 'package:hmg_patient_app_new/features/notifications/notifications_view_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_repo.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_view_model.dart'; import 'package:hmg_patient_app_new/features/payfort/payfort_repo.dart'; import 'package:hmg_patient_app_new/features/payfort/payfort_view_model.dart'; import 'package:hmg_patient_app_new/features/paytabs/paytabs_view_model.dart'; import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_repo.dart'; import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_view_model.dart'; +import 'package:hmg_patient_app_new/features/profile_picture/profile_picture_view_model.dart'; import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_repo.dart'; import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart'; import 'package:hmg_patient_app_new/features/qr_parking/qr_parking_repo.dart'; @@ -78,6 +81,7 @@ import 'package:hmg_patient_app_new/services/localauth_service.dart'; import 'package:hmg_patient_app_new/services/logger_service.dart'; import 'package:hmg_patient_app_new/services/navigation_service.dart'; import 'package:hmg_patient_app_new/services/notification_service.dart'; +import 'package:hmg_patient_app_new/services/permission_service.dart'; import 'package:hmg_patient_app_new/widgets/date_range_selector/viewmodel/date_range_calendar_model.dart'; import 'package:hmg_patient_app_new/widgets/date_range_selector/viewmodel/date_range_view_model.dart'; import 'package:local_auth/local_auth.dart'; @@ -150,6 +154,8 @@ class AppDependencies { () => LocalAuthService(loggerService: getIt(), localAuth: getIt()), ); + getIt.registerLazySingleton(() => PermissionService()); + // Repositories getIt.registerLazySingleton(() => CommonRepoImp(loggerService: getIt())); getIt.registerLazySingleton(() => AuthenticationRepoImp(loggerService: getIt(), apiClient: getIt())); @@ -189,6 +195,7 @@ class AppDependencies { getIt.registerLazySingleton(() => AskDoctorRepoImp(loggerService: getIt(), apiClient: getIt())); getIt.registerLazySingleton(() => ServicesPriceListRepoImp(loggerService: getIt(), apiClient: getIt())); getIt.registerLazySingleton(() => ProfileSettingsRepoImp(loggerService: getIt(), apiClient: getIt())); + getIt.registerLazySingleton(() => OffersAndDiscountsRepoImp(loggerService: getIt(), apiClient: getIt())); // ViewModels // Global/shared VMs β†’ LazySingleton @@ -247,6 +254,11 @@ class AppDependencies { errorHandlerService: getIt(), )); + getIt.registerLazySingleton(() => ProfilePictureViewModel( + appState: getIt(), + profileSettingsViewModel: getIt(), + )); + getIt.registerLazySingleton(() => DateRangeSelectorRangeViewModel()); getIt.registerLazySingleton(() => DoctorFilterViewModel()); @@ -327,6 +339,9 @@ class AppDependencies { getIt.registerLazySingleton(() => AskDoctorViewModel(askDoctorRepo: getIt(), errorHandlerService: getIt())); getIt.registerLazySingleton(() => ServicesPriceListViewModel(servicesPriceListRepo: getIt(), errorHandlerService: getIt())); + + getIt.registerLazySingleton(() => OffersAndDiscountsViewModel(offersAndDiscountsRepo: getIt(), errorHandlerService: getIt())); + getIt.registerLazySingleton(() => DateRangCalenderModel(appState: getIt(), navigationService: getIt(), dialogService: getIt())); getIt.registerLazySingleton(() => PayTabsViewModel(appState: getIt())); diff --git a/lib/features/medical_file/medical_file_view_model.dart b/lib/features/medical_file/medical_file_view_model.dart index 8b7ce7c3..41a726d9 100644 --- a/lib/features/medical_file/medical_file_view_model.dart +++ b/lib/features/medical_file/medical_file_view_model.dart @@ -805,8 +805,13 @@ class MedicalFileViewModel extends ChangeNotifier { } if (updated) { + // Create new list instances to trigger Selector rebuild + patientFamilyFiles = List.from(patientFamilyFiles); + pendingFamilyFiles = List.from(pendingFamilyFiles); + // Notify listeners to update UI notifyListeners(); + print("πŸ”„ Created new list instances and notified listeners"); } else { print("⚠️ Family member not found in cache for patientID: $patientID"); } diff --git a/lib/features/my_appointments/my_appointments_view_model.dart b/lib/features/my_appointments/my_appointments_view_model.dart index e2c456fb..0ad21586 100644 --- a/lib/features/my_appointments/my_appointments_view_model.dart +++ b/lib/features/my_appointments/my_appointments_view_model.dart @@ -324,7 +324,7 @@ class MyAppointmentsViewModel extends ChangeNotifier { // if (patientArrivedAppointmentsHistoryList.isNotEmpty) { isPatientHasQueueAppointment = false; notifyListeners(); - if(patientArrivedAppointmentsHistoryList.isNotEmpty) { + if (patientArrivedAppointmentsHistoryList.isNotEmpty) { if (Utils.isDateToday(DateUtil.convertStringToDate(patientArrivedAppointmentsHistoryList.first.appointmentDate))) { // getPatientAppointmentQueueDetails(appointmentNo: patientArrivedAppointmentsHistoryList.first.appointmentNo, patientID: patientArrivedAppointmentsHistoryList.first.patientID); getPatientAppointmentQueueDetails(); @@ -948,7 +948,8 @@ class MyAppointmentsViewModel extends ChangeNotifier { final result = await myAppointmentsRepo.getPatientAppointmentQueueDetails( clinicID: patientArrivedAppointmentsHistoryList.first.clinicID, - appointmentNo: patientArrivedAppointmentsHistoryList.first.appointmentNo, patientID: patientArrivedAppointmentsHistoryList.first.patientID); + appointmentNo: patientArrivedAppointmentsHistoryList.first.appointmentNo, + patientID: patientArrivedAppointmentsHistoryList.first.patientID); isAppointmentQueueDetailsLoading = false; @@ -1203,7 +1204,8 @@ class MyAppointmentsViewModel extends ChangeNotifier { switch (reminderType) { case ReminderType.appointment: - eventTitle = "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} on ${DateUtil.convertStringToDate(patientAppointmentHistoryResponseModel.appointmentDate)}, Appointment #${patientAppointmentHistoryResponseModel.appointmentNo}"; + eventTitle = + "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} on ${DateUtil.convertStringToDate(patientAppointmentHistoryResponseModel.appointmentDate)}, Appointment #${patientAppointmentHistoryResponseModel.appointmentNo}"; eventDescription = "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} in ${patientAppointmentHistoryResponseModel.projectName}"; break; case ReminderType.payment: @@ -1436,7 +1438,8 @@ class MyAppointmentsViewModel extends ChangeNotifier { switch (reminderType) { case ReminderType.appointment: - eventTitle = "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} on ${DateUtil.convertStringToDate(patientAppointmentHistoryResponseModel.appointmentDate)}, Appointment #${patientAppointmentHistoryResponseModel.appointmentNo}"; + eventTitle = + "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} on ${DateUtil.convertStringToDate(patientAppointmentHistoryResponseModel.appointmentDate)}, Appointment #${patientAppointmentHistoryResponseModel.appointmentNo}"; eventDescription = "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} in ${patientAppointmentHistoryResponseModel.projectName}"; break; case ReminderType.payment: @@ -1507,7 +1510,7 @@ class MyAppointmentsViewModel extends ChangeNotifier { required BuildContext context, required bool shouldCreateReminder, required PatientAppointmentHistoryResponseModel patientAppointmentHistoryResponseModel, - required ReminderType reminderType , + required ReminderType reminderType, Function(bool)? onSuccess, Function(String)? onError, }) async { diff --git a/lib/features/offers_and_discounts/models/offers_and_discounts_response_model.dart b/lib/features/offers_and_discounts/models/offers_and_discounts_response_model.dart new file mode 100644 index 00000000..4a1a250f --- /dev/null +++ b/lib/features/offers_and_discounts/models/offers_and_discounts_response_model.dart @@ -0,0 +1,52 @@ +class OffersAndDiscountsResponseModel { + int? id; + String? title; + String? description; + String? imageUrl; + String? facilityType; // HMC, HMG, or Both + String? startDate; + String? endDate; + String? discount; + bool? isActive; + + OffersAndDiscountsResponseModel({ + this.id, + this.title, + this.description, + this.imageUrl, + this.facilityType, + this.startDate, + this.endDate, + this.discount, + this.isActive, + }); + + factory OffersAndDiscountsResponseModel.fromJson(Map json) { + return OffersAndDiscountsResponseModel( + id: json['ID'] as int?, + title: json['Title'] as String?, + description: json['Description'] as String?, + imageUrl: json['ImageURL'] as String?, + facilityType: json['FacilityType'] as String?, + startDate: json['StartDate'] as String?, + endDate: json['EndDate'] as String?, + discount: json['Discount'] as String?, + isActive: json['IsActive'] as bool?, + ); + } + + Map toJson() { + return { + 'ID': id, + 'Title': title, + 'Description': description, + 'ImageURL': imageUrl, + 'FacilityType': facilityType, + 'StartDate': startDate, + 'EndDate': endDate, + 'Discount': discount, + 'IsActive': isActive, + }; + } +} + diff --git a/lib/features/offers_and_discounts/offers_and_discounts_repo.dart b/lib/features/offers_and_discounts/offers_and_discounts_repo.dart new file mode 100644 index 00000000..53f26a6f --- /dev/null +++ b/lib/features/offers_and_discounts/offers_and_discounts_repo.dart @@ -0,0 +1,102 @@ +import 'package:dartz/dartz.dart'; +import 'package:hmg_patient_app_new/core/api/api_client.dart'; +import 'package:hmg_patient_app_new/core/api_consts.dart'; +import 'package:hmg_patient_app_new/core/common_models/generic_api_model.dart'; +import 'package:hmg_patient_app_new/core/exceptions/api_failure.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_and_discounts_response_model.dart'; +import 'package:hmg_patient_app_new/services/logger_service.dart'; + +abstract class OffersAndDiscountsRepo { + Future>>> getOffersAndDiscounts(); + Future>>> getOffersAndDiscountsHistory(); +} + +class OffersAndDiscountsRepoImp implements OffersAndDiscountsRepo { + final ApiClient apiClient; + final LoggerService loggerService; + + OffersAndDiscountsRepoImp({required this.loggerService, required this.apiClient}); + + @override + Future>>> getOffersAndDiscounts() async { + Map mapDevice = {}; + + try { + GenericApiModel>? apiResponse; + Failure? failure; + await apiClient.post( + GET_OFFERS_AND_DISCOUNTS, // TODO: Replace with actual API endpoint + body: mapDevice, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + final list = response['List_OffersAndDiscounts']; + if (list == null || list.isEmpty) { + throw Exception("offers and discounts list is empty"); + } + + final offers = list.map((item) => OffersAndDiscountsResponseModel.fromJson(item as Map)).toList().cast(); + + apiResponse = GenericApiModel>( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: offers, + ); + } 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())); + } + } + + @override + Future>>> getOffersAndDiscountsHistory() async { + Map mapDevice = {}; + + try { + GenericApiModel>? apiResponse; + Failure? failure; + await apiClient.post( + GET_OFFERS_AND_DISCOUNTS_HISTORY, // TODO: Replace with actual API endpoint + body: mapDevice, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + final list = response['List_OffersAndDiscountsHistory']; + if (list == null || list.isEmpty) { + throw Exception("offers and discounts history list is empty"); + } + + final historyOffers = list.map((item) => OffersAndDiscountsResponseModel.fromJson(item as Map)).toList().cast(); + + apiResponse = GenericApiModel>( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: historyOffers, + ); + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, + ); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } +} + diff --git a/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart b/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart new file mode 100644 index 00000000..88e15ce6 --- /dev/null +++ b/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart @@ -0,0 +1,449 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_and_discounts_response_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_repo.dart'; +import 'package:hmg_patient_app_new/services/error_handler_service.dart'; + +class OffersAndDiscountsViewModel extends ChangeNotifier { + bool isOffersLoading = false; + bool isHistoryLoading = false; + List selectedFacilities = ['All Offers']; // Default to 'All Offers' + String searchQuery = ''; + + OffersAndDiscountsRepo offersAndDiscountsRepo; + ErrorHandlerService errorHandlerService; + + List offersList = []; + List historyList = []; + + OffersAndDiscountsViewModel({ + required this.offersAndDiscountsRepo, + required this.errorHandlerService, + }) { + _initializeDummyData(); + } + + // Initialize with dummy data for testing + void _initializeDummyData() { + offersList = [ + OffersAndDiscountsResponseModel( + id: 1, + title: 'Summer Health Checkup Package', + description: 'Complete health screening with blood tests, X-ray, and consultation. Get 30% off on all diagnostic services.', + imageUrl: 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?w=800', + facilityType: 'Female', + startDate: '2026-05-01', + endDate: '2026-08-31', + discount: '30% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 2, + title: 'Maternity Care Special', + description: 'Comprehensive prenatal care package including ultrasound, consultations, and postnatal support.', + imageUrl: 'https://images.unsplash.com/photo-1584820927498-cfe5211fd8bf?w=800', + facilityType: 'OB-Gyne', + startDate: '2026-05-01', + endDate: '2026-12-31', + discount: '25% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 3, + title: 'Skin Treatment Package', + description: 'Advanced dermatology services including acne treatment, anti-aging procedures, and skin rejuvenation.', + imageUrl: 'https://images.unsplash.com/photo-1570172619644-dfd03ed5d881?w=800', + facilityType: 'Dermatology', + startDate: '2026-05-15', + endDate: '2026-07-15', + discount: '40% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 4, + title: 'CT & MRI Scanning Discount', + description: 'State-of-the-art imaging services with latest technology. Book your scan today!', + imageUrl: 'https://images.unsplash.com/photo-1516549655169-df83a0774514?w=800', + facilityType: 'Radiology', + startDate: '2026-05-01', + endDate: '2026-06-30', + discount: '20% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 5, + title: 'Women\'s Health Screening', + description: 'Comprehensive health package designed specifically for women including mammography, bone density scan, and hormone tests.', + imageUrl: 'https://images.unsplash.com/photo-1559757175-5700dde675bc?w=800', + facilityType: 'Female', + startDate: '2026-05-10', + endDate: '2026-09-30', + discount: '35% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 6, + title: 'Gynecology Consultation Special', + description: 'Free follow-up consultation with every initial gynecology visit. Expert care for all women\'s health needs.', + imageUrl: 'https://images.unsplash.com/photo-1631217868264-e5b90bb7e133?w=800', + facilityType: 'OB-Gyne', + startDate: '2026-05-01', + endDate: '2026-07-31', + discount: 'FREE Follow-up', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 7, + title: 'Laser Hair Removal Package', + description: 'Professional laser hair removal treatment with latest technology. Multiple sessions available.', + imageUrl: 'https://images.unsplash.com/photo-1612349317150-e413f6a5b16d?w=800', + facilityType: 'Dermatology', + startDate: '2026-05-20', + endDate: '2026-10-20', + discount: '50% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 8, + title: 'X-Ray & Ultrasound Combo', + description: 'Get both X-ray and ultrasound services at a discounted price. Quick results guaranteed.', + imageUrl: 'https://images.unsplash.com/photo-1530497610245-94d3c16cda28?w=800', + facilityType: 'Radiology', + startDate: '2026-05-01', + endDate: '2026-08-15', + discount: '15% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 9, + title: 'Complete Wellness Package', + description: 'Full body checkup including all major tests, consultations, and health assessment report.', + imageUrl: 'https://images.unsplash.com/photo-1505751172876-fa1923c5c528?w=800', + facilityType: 'Both', + startDate: '2026-05-01', + endDate: '2026-12-31', + discount: '45% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 10, + title: 'Botox & Filler Treatment', + description: 'Anti-aging treatments with certified dermatologists. Natural-looking results guaranteed.', + imageUrl: 'https://images.unsplash.com/photo-1515377905703-c4788e51af15?w=800', + facilityType: 'Dermatology', + startDate: '2026-05-15', + endDate: '2026-06-15', + discount: '30% OFF', + isActive: true, + ), + ]; + + // Past offers for history + historyList = [ + OffersAndDiscountsResponseModel( + id: 101, + title: 'Winter Health Campaign', + description: 'Flu shots and winter wellness packages that were offered during the winter season.', + imageUrl: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=800', + facilityType: 'Both', + startDate: '2025-12-01', + endDate: '2026-02-28', + discount: '25% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 102, + title: 'Valentine\'s Day Couple Checkup', + description: 'Special couple health screening packages offered for Valentine\'s Day.', + imageUrl: 'https://images.unsplash.com/photo-1516549655169-df83a0774514?w=800', + facilityType: 'Both', + startDate: '2026-02-01', + endDate: '2026-02-14', + discount: '40% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 103, + title: 'Spring Skin Renewal', + description: 'Spring special dermatology treatments for skin rejuvenation.', + imageUrl: 'https://images.unsplash.com/photo-1556228720-195a672e8a03?w=800', + facilityType: 'Dermatology', + startDate: '2026-03-01', + endDate: '2026-04-30', + discount: '30% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 104, + title: 'Summer Wellness Package - Active', + description: 'Active offer for comprehensive summer health checkup with multiple tests.', + imageUrl: 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?w=800', + facilityType: 'Female', + startDate: '2026-05-01', + endDate: '2026-08-31', + discount: '35% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 105, + title: 'Kids Health Screening - Active', + description: 'Currently active pediatric health screening package for children.', + imageUrl: 'https://images.unsplash.com/photo-1559839734-2b71ea197ec2?w=800', + facilityType: 'Both', + startDate: '2026-05-05', + endDate: '2026-09-30', + discount: '20% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 106, + title: 'Laser Treatment Offer - Expired', + description: 'Laser hair removal package that has expired.', + imageUrl: 'https://images.unsplash.com/photo-1612349317150-e413f6a5b16d?w=800', + facilityType: 'Dermatology', + startDate: '2026-01-01', + endDate: '2026-03-31', + discount: '50% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 107, + title: 'Cardiology Consultation Package', + description: 'Heart health checkup with ECG and consultation.', + imageUrl: 'https://images.unsplash.com/photo-1628348068343-c6a848d2b6dd?w=800', + facilityType: 'Both', + startDate: '2025-11-01', + endDate: '2026-01-31', + discount: '25% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 108, + title: 'Dental Care Special - Active', + description: 'Comprehensive dental checkup and cleaning package.', + imageUrl: 'https://images.unsplash.com/photo-1606811971618-4486d14f3f99?w=800', + facilityType: 'Both', + startDate: '2026-04-01', + endDate: '2026-12-31', + discount: '30% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 109, + title: 'Eye Care Package - Expired', + description: 'Complete eye examination with vision testing.', + imageUrl: 'https://images.unsplash.com/photo-1516534775068-ba3e7458af70?w=800', + facilityType: 'Both', + startDate: '2025-10-01', + endDate: '2025-12-31', + discount: '20% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 110, + title: 'Maternity Care Premium', + description: 'Premium prenatal and postnatal care package.', + imageUrl: 'https://images.unsplash.com/photo-1584820927498-cfe5211fd8bf?w=800', + facilityType: 'OB-Gyne', + startDate: '2026-01-15', + endDate: '2026-06-30', + discount: '40% OFF', + isActive: true, + ), + OffersAndDiscountsResponseModel( + id: 111, + title: 'Blood Test Special - Expired', + description: 'Comprehensive blood work panel at discounted rates.', + imageUrl: 'https://images.unsplash.com/photo-1579154204601-01588f351e67?w=800', + facilityType: 'Both', + startDate: '2025-09-01', + endDate: '2025-11-30', + discount: '15% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 112, + title: 'Vaccination Drive', + description: 'Special vaccination package for adults and children.', + imageUrl: 'https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=800', + facilityType: 'Both', + startDate: '2026-03-01', + endDate: '2026-05-05', + discount: '10% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 113, + title: 'Radiology Imaging Package - Active', + description: 'CT, MRI, and X-ray services at reduced prices.', + imageUrl: 'https://images.unsplash.com/photo-1516549655169-df83a0774514?w=800', + facilityType: 'Radiology', + startDate: '2026-05-01', + endDate: '2026-10-31', + discount: '25% OFF', + isActive: false, // Changed to false - will show as "Availed" + ), + OffersAndDiscountsResponseModel( + id: 114, + title: 'Physiotherapy Sessions', + description: 'Package of 10 physiotherapy sessions.', + imageUrl: 'https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=800', + facilityType: 'Both', + startDate: '2025-12-01', + endDate: '2026-04-01', + discount: '35% OFF', + isActive: false, + ), + OffersAndDiscountsResponseModel( + id: 115, + title: 'Nutrition Consultation Bundle - Active', + description: 'Series of nutrition and diet consultation sessions.', + imageUrl: 'https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=800', + facilityType: 'Both', + startDate: '2026-05-01', + endDate: '2026-11-30', + discount: '20% OFF', + isActive: true, + ), + ]; + } + + // For loading state compatibility with existing code + bool get isRegionListLoading => isOffersLoading; + + initOffersAndDiscounts() { + // Don't clear the list - keep dummy data until API returns + // Don't show loading state since we have dummy data + // offersList.clear(); + // isOffersLoading = true; + notifyListeners(); + getOffersAndDiscounts(); + } + + setIsOffersLoading(bool val) { + isOffersLoading = val; + notifyListeners(); + } + + setIsHistoryLoading(bool val) { + isHistoryLoading = val; + notifyListeners(); + } + + setSelectedFacility(List facilities) { + selectedFacilities = facilities; + notifyListeners(); + } + + setSearchQuery(String query) { + searchQuery = query; + notifyListeners(); + } + + Future getOffersAndDiscounts({Function(dynamic)? onSuccess, Function(String)? onError}) async { + final result = await offersAndDiscountsRepo.getOffersAndDiscounts(); + + result.fold( + (failure) async { + isOffersLoading = false; + notifyListeners(); + // Keep dummy data if API fails - don't clear the list + // await errorHandlerService.handleError(failure: failure); + if (onError != null) { + onError(failure.toString()); + } + }, + (response) { + isOffersLoading = false; + if (response.data != null && response.data!.isNotEmpty) { + offersList = response.data!; + } + // If API returns empty or null, keep the dummy data + notifyListeners(); + if (onSuccess != null) { + onSuccess(response); + } + }, + ); + } + + Future getOffersAndDiscountsHistory({Function(dynamic)? onSuccess, Function(String)? onError}) async { + isHistoryLoading = true; + notifyListeners(); + + final result = await offersAndDiscountsRepo.getOffersAndDiscountsHistory(); + + result.fold( + (failure) async { + isHistoryLoading = false; + notifyListeners(); + // Keep dummy data if API fails - don't clear the list + // await errorHandlerService.handleError(failure: failure); + if (onError != null) { + onError(failure.toString()); + } + }, + (response) { + isHistoryLoading = false; + if (response.data != null && response.data!.isNotEmpty) { + historyList = response.data!; + } + // If API returns empty or null, keep the dummy data + notifyListeners(); + if (onSuccess != null) { + onSuccess(response); + } + }, + ); + } + + List get filteredOffers { + List filtered = offersList; + + // Filter by facility types (multi-select support) + if (!selectedFacilities.contains('All Offers')) { + filtered = filtered.where((offer) { + // Check if offer's facility type matches any of the selected types + return selectedFacilities.any((selectedType) => + offer.facilityType == selectedType || offer.facilityType == 'Both' + ); + }).toList(); + } + + // Filter by search query + if (searchQuery.isNotEmpty) { + filtered = filtered.where((offer) { + final titleMatch = offer.title?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false; + final descriptionMatch = offer.description?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false; + return titleMatch || descriptionMatch; + }).toList(); + } + + return filtered; + } + + List get filteredHistory { + List filtered = historyList; + + // Filter by facility types (multi-select support) + if (!selectedFacilities.contains('All Offers')) { + filtered = filtered.where((offer) { + // Check if offer's facility type matches any of the selected types + return selectedFacilities.any((selectedType) => + offer.facilityType == selectedType || offer.facilityType == 'Both' + ); + }).toList(); + } + + // Filter by search query + if (searchQuery.isNotEmpty) { + filtered = filtered.where((offer) { + final titleMatch = offer.title?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false; + final descriptionMatch = offer.description?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false; + return titleMatch || descriptionMatch; + }).toList(); + } + + return filtered; + } +} + diff --git a/lib/features/profile_settings/profile_settings_view_model.dart b/lib/features/profile_settings/profile_settings_view_model.dart index 64db2481..02cbc10f 100644 --- a/lib/features/profile_settings/profile_settings_view_model.dart +++ b/lib/features/profile_settings/profile_settings_view_model.dart @@ -381,14 +381,20 @@ class ProfileSettingsViewModel extends ChangeNotifier { (response) { isUploadingProfileImage = false; profileImageData = imageData; - // Store in AppState for global access + + // Store in AppState for global access FIRST GetIt.instance().setProfileImageData = imageData; - // Update the family files cache with the new profile image + // Update the family files cache with the new profile image (after AppState update) try { final medicalFileViewModel = GetIt.instance.get(); - medicalFileViewModel.updateFamilyMemberProfileImage(patientID, imageData); - print("βœ… Updated profile image in family files cache for patient: $patientID"); + // Only update if family files are loaded + if (medicalFileViewModel.patientFamilyFiles.isNotEmpty || + medicalFileViewModel.pendingFamilyFiles.isNotEmpty) { + medicalFileViewModel.updateFamilyMemberProfileImage(patientID, imageData); + } else { + print("ℹ️ Family files not loaded yet, skipping cache update"); + } } catch (e) { print("⚠️ Could not update family files cache: $e"); } diff --git a/lib/main.dart b/lib/main.dart index 1ebcf0d6..e2643438 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,8 +35,10 @@ import 'package:hmg_patient_app_new/features/my_appointments/appointment_via_reg import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart'; import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_view_model.dart'; import 'package:hmg_patient_app_new/features/notifications/notifications_view_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_view_model.dart'; import 'package:hmg_patient_app_new/features/payfort/payfort_view_model.dart'; import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_view_model.dart'; +import 'package:hmg_patient_app_new/features/profile_picture/profile_picture_view_model.dart'; import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart'; import 'package:hmg_patient_app_new/features/qr_parking/qr_parking_view_model.dart'; import 'package:hmg_patient_app_new/features/radiology/radiology_view_model.dart'; @@ -169,6 +171,9 @@ void main() async { ChangeNotifierProvider( create: (_) => getIt.get(), ), + ChangeNotifierProvider( + create: (_) => getIt.get(), + ), ChangeNotifierProvider( create: (_) => getIt.get(), ), @@ -258,7 +263,10 @@ void main() async { ), ChangeNotifierProvider( create: (_) => getIt.get(), - ) , + ), + ChangeNotifierProvider( + create: (_) => getIt.get(), + ), ChangeNotifierProvider( create: (_) => getIt.get(), ), ChangeNotifierProvider( diff --git a/lib/presentation/home/landing_page.dart b/lib/presentation/home/landing_page.dart index f31a04cc..15bce8b0 100644 --- a/lib/presentation/home/landing_page.dart +++ b/lib/presentation/home/landing_page.dart @@ -29,6 +29,7 @@ import 'package:hmg_patient_app_new/features/my_appointments/appointment_rating_ import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/patient_appointment_history_response_model.dart'; import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart'; import 'package:hmg_patient_app_new/features/notifications/notifications_view_model.dart'; +import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart'; import 'package:hmg_patient_app_new/features/paytabs/paytabs_view_model.dart'; import 'package:hmg_patient_app_new/features/todo_section/todo_section_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; @@ -50,6 +51,8 @@ import 'package:hmg_patient_app_new/presentation/insurance/widgets/insurance_upd import 'package:hmg_patient_app_new/presentation/medical_file/medical_file_page.dart'; import 'package:hmg_patient_app_new/presentation/my_family/my_family.dart'; import 'package:hmg_patient_app_new/presentation/notifications/notifications_list_page.dart'; +import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offers_and_discounts_page.dart'; +import 'package:hmg_patient_app_new/presentation/offers_and_discounts/widgets/offers_and_discounts.dart'; import 'package:hmg_patient_app_new/presentation/rate_appointment/rate_appointment_doctor.dart'; import 'package:hmg_patient_app_new/presentation/todo_section/ancillary_procedures_details_page.dart'; import 'package:hmg_patient_app_new/presentation/todo_section/todo_page.dart'; @@ -240,11 +243,20 @@ class _LandingPageState extends State { ); }, name: ('${appState.getAuthenticatedUser()!.firstName!} ${appState.getAuthenticatedUser()!.lastName!}'), - imageWidget: UserAvatarWidget( - width: 42.w, - height: 42.h, - fit: BoxFit.cover, - isCircular: true, + imageWidget: Selector( + selector: (_, profileVM) => profileVM.profileImageData ?? appState.getProfileImageData, + shouldRebuild: (previous, next) => previous != next, + builder: (context, profileImageData, child) { + // Only rebuild when profile image data changes + return UserAvatarWidget( + key: ValueKey('landing_avatar_${profileImageData?.hashCode ?? 0}'), + width: 42.w, + height: 42.h, + fit: BoxFit.cover, + isCircular: true, + customProfileImageData: profileImageData, + ); + }, ), ).expanded : CustomButton( @@ -336,9 +348,7 @@ class _LandingPageState extends State { // }), !appState.isAuthenticated ? Row(children: [ - SizedBox( - width: 24.w, - ), + SizedBox(width: 24.w), Utils.buildSvgWithAssets(icon: appState.isArabic() ? AppAssets.enLangIcon : AppAssets.arLangIcon, height: 24.h, width: 24.h).onPress(() { context.setLocale(appState.isArabic() ? Locale('en', 'US') : Locale('ar', 'SA')); }) @@ -394,6 +404,34 @@ class _LandingPageState extends State { ), ).paddingSymmetrical(24.w, 0.h) : SizedBox.shrink(), + + // Offers And Discounts Carousel - Auto-scrolling from right to left + appState.isAuthenticated && appState.isEnabledOffersAndDiscountsCarousel + ? Column( + children: [ + SizedBox(height: 12.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ("${LocaleKeys.offers.tr(context: context)} & ${LocaleKeys.discount.tr(context: context)}").toText16(isBold: true), + Row( + children: [ + LocaleKeys.viewAll.tr(context: context).toText14(color: AppColors.primaryRedColor, isBold: true), + SizedBox(width: 2.h), + Icon(Icons.arrow_forward_ios, color: AppColors.primaryRedColor, size: 14.h), + ], + ), + ], + ).paddingSymmetrical(24.h, 0.h).onPress(() { + Navigator.of(context).push(CustomPageRoute(page: OffersAndDiscountsPage())); + }), + SizedBox(height: 16.h), + OffersAndDiscountsCarousel().paddingSymmetrical(24.h, 0.h), + SizedBox(height: 18.h), + ], + ) + : SizedBox.shrink(), + appState.isAuthenticated ? Column( children: [ diff --git a/lib/presentation/medical_file/medical_file_page.dart b/lib/presentation/medical_file/medical_file_page.dart index c22e261d..e281743a 100644 --- a/lib/presentation/medical_file/medical_file_page.dart +++ b/lib/presentation/medical_file/medical_file_page.dart @@ -30,6 +30,7 @@ import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/ import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart'; import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_view_model.dart'; import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_view_model.dart'; +import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart'; import 'package:hmg_patient_app_new/features/todo_section/todo_section_view_model.dart'; import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; @@ -218,11 +219,19 @@ class _MedicalFilePageState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - UserAvatarWidget( - width: 56.h, - height: 56.h, - fit: BoxFit.cover, - isCircular: true, + Selector( + selector: (_, profileVM) => profileVM.profileImageData ?? appState.getProfileImageData, + shouldRebuild: (previous, next) => previous != next, + builder: (context, profileImageData, child) { + return UserAvatarWidget( + key: ValueKey('medical_avatar_${profileImageData?.hashCode ?? 0}'), + width: 56.h, + height: 56.h, + fit: BoxFit.cover, + isCircular: true, + customProfileImageData: profileImageData, + ); + }, ), SizedBox(width: 8.w), Column( @@ -265,9 +274,7 @@ class _MedicalFilePageState extends State { runSpacing: 4.h, children: [ AppCustomChipWidget( - labelText: LocaleKeys.ageYearsOld.tr( - namedArgs: {'age': '${appState.getAuthenticatedUser()!.age}', 'yearsOld': LocaleKeys.yearsOld.tr(context: context)}, - context: context), + labelText: LocaleKeys.ageYearsOld.tr(namedArgs: {'age': '${appState.getAuthenticatedUser()!.age}', 'yearsOld': LocaleKeys.yearsOld.tr(context: context)}, context: context), labelPadding: EdgeInsetsDirectional.only(start: 8.w, end: 8.w), ), AppCustomChipWidget( @@ -310,14 +317,9 @@ class _MedicalFilePageState extends State { onChipTap: () { if (!insuranceVM.isInsuranceActive) { insuranceVM.setIsInsuranceUpdateDetailsLoading(true); - insuranceVM.getPatientInsuranceDetailsForUpdate(appState.getAuthenticatedUser()!.patientId.toString(), - appState.getAuthenticatedUser()!.patientIdentificationNo.toString()); - showCommonBottomSheetWithoutHeight(context, - child: PatientInsuranceCardUpdateCard(), - callBackFunc: () {}, - title: "", - isCloseButtonVisible: false, - isFullScreen: false); + insuranceVM.getPatientInsuranceDetailsForUpdate( + appState.getAuthenticatedUser()!.patientId.toString(), appState.getAuthenticatedUser()!.patientIdentificationNo.toString()); + showCommonBottomSheetWithoutHeight(context, child: PatientInsuranceCardUpdateCard(), callBackFunc: () {}, title: "", isCloseButtonVisible: false, isFullScreen: false); // showCommonBottomSheetWithoutHeight( // title: LocaleKeys.notice.tr(context: navigationService.navigatorKey.currentContext!), // navigationService.navigatorKey.currentContext!, @@ -491,13 +493,10 @@ class _MedicalFilePageState extends State { getSelectedTabData(0), ], ), - ExpandableListItem( - title: LocaleKeys.medicalReports.tr(context: context).toText18(isBold: true), - expandedBackgroundColor: Colors.transparent, - children: [ - SizedBox(height: 10.h), - getSelectedTabData(2), - ]), + ExpandableListItem(title: LocaleKeys.medicalReports.tr(context: context).toText18(isBold: true), expandedBackgroundColor: Colors.transparent, children: [ + SizedBox(height: 10.h), + getSelectedTabData(2), + ]), ExpandableListItem( title: LocaleKeys.insuranceAndPayments.tr(context: context).toText18(isBold: true), expandedBackgroundColor: Colors.transparent, @@ -601,14 +600,9 @@ class _MedicalFilePageState extends State { text: "${LocaleKeys.updateInsurance.tr(context: context)} ${LocaleKeys.updateInsuranceSubtitle.tr(context: context)}", onPressed: () { insuranceViewModel.setIsInsuranceUpdateDetailsLoading(true); - insuranceViewModel.getPatientInsuranceDetailsForUpdate(appState.getAuthenticatedUser()!.patientId.toString(), - appState.getAuthenticatedUser()!.patientIdentificationNo.toString()); - showCommonBottomSheetWithoutHeight(context, - child: PatientInsuranceCardUpdateCard(), - callBackFunc: () {}, - title: "", - isCloseButtonVisible: false, - isFullScreen: false); + insuranceViewModel.getPatientInsuranceDetailsForUpdate( + appState.getAuthenticatedUser()!.patientId.toString(), appState.getAuthenticatedUser()!.patientIdentificationNo.toString()); + showCommonBottomSheetWithoutHeight(context, child: PatientInsuranceCardUpdateCard(), callBackFunc: () {}, title: "", isCloseButtonVisible: false, isFullScreen: false); }, backgroundColor: AppColors.bgGreenColor.withOpacity(0.20), borderColor: AppColors.bgGreenColor.withOpacity(0.0), @@ -733,8 +727,7 @@ class _MedicalFilePageState extends State { ? Container( padding: EdgeInsets.all(12.w), width: MediaQuery.of(context).size.width, - decoration: - RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 12.r, hasShadow: false), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 12.r, hasShadow: false), child: Column( children: [ Utils.buildSvgWithAssets(icon: AppAssets.home_calendar_icon, width: 32.h, height: 32.h), @@ -893,8 +886,7 @@ class _MedicalFilePageState extends State { ? const CommonShimmerWidget().paddingSymmetrical(0.w, 0.h) : prescriptionVM.patientPrescriptionOrders.isNotEmpty ? Container( - decoration: - RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.r, hasShadow: false), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.r, hasShadow: false), child: Padding( padding: EdgeInsets.all(16.w), child: Column( @@ -931,15 +923,13 @@ class _MedicalFilePageState extends State { spacing: 3.w, runSpacing: 4.w, children: [ - AppCustomChipWidget( - labelText: prescriptionVM.patientPrescriptionOrders[index].clinicDescription!), + AppCustomChipWidget(labelText: prescriptionVM.patientPrescriptionOrders[index].clinicDescription!), Directionality( textDirection: ui.TextDirection.ltr, child: AppCustomChipWidget( icon: AppAssets.doctor_calendar_icon, labelText: DateUtil.formatDateToDate( - DateUtil.convertStringToDate( - prescriptionVM.patientPrescriptionOrders[index].appointmentDate), + DateUtil.convertStringToDate(prescriptionVM.patientPrescriptionOrders[index].appointmentDate), false, ), isEnglishOnly: true, @@ -953,20 +943,14 @@ class _MedicalFilePageState extends State { // SizedBox(width: 40.h), Transform.flip( flipX: appState.isArabic(), - child: Utils.buildSvgWithAssets( - icon: AppAssets.forward_arrow_icon_small, - width: 15.w, - height: 15.h, - fit: BoxFit.contain, - iconColor: AppColors.textColor)), + child: + Utils.buildSvgWithAssets(icon: AppAssets.forward_arrow_icon_small, width: 15.w, height: 15.h, fit: BoxFit.contain, iconColor: AppColors.textColor)), ], ).onPress(() { prescriptionVM.setPrescriptionsDetailsLoading(); Navigator.of(context).push( CustomPageRoute( - page: PrescriptionDetailPage( - isFromAppointments: false, - prescriptionsResponseModel: prescriptionVM.patientPrescriptionOrders[index]), + page: PrescriptionDetailPage(isFromAppointments: false, prescriptionsResponseModel: prescriptionVM.patientPrescriptionOrders[index]), ), ); }), @@ -1128,10 +1112,7 @@ class _MedicalFilePageState extends State { SizedBox(height: 8.h), SizedBox( width: 80.w, - child: (myAppointmentsVM.patientMyDoctorsList[index].doctorName) - .toString() - .toText12(isBold: true, isCenter: true, maxLine: 2) - .toShimmer2(isShow: false), + child: (myAppointmentsVM.patientMyDoctorsList[index].doctorName).toString().toText12(isBold: true, isCenter: true, maxLine: 2).toShimmer2(isShow: false), ), ], ), @@ -1146,8 +1127,7 @@ class _MedicalFilePageState extends State { LoaderBottomSheet.hideLoader(); Navigator.of(context).push( CustomPageRoute( - page: DoctorProfilePage( - isDoctorAllowedToBook: !(myAppointmentsVM.patientMyDoctorsList[index].isLiveCareClinic ?? false)), + page: DoctorProfilePage(isDoctorAllowedToBook: !(myAppointmentsVM.patientMyDoctorsList[index].isLiveCareClinic ?? false)), ), ); }, onError: (err) { @@ -1284,13 +1264,8 @@ class _MedicalFilePageState extends State { ); }), SizedBox(height: 16.h), - Selector listRequest, List listReady})>( - selector: (context, vm) => ( - isLoading: vm.isPatientMedicalReportsListLoading, - listRequest: vm.patientMedicalReportRequestedList, - listReady: vm.patientMedicalReportReadyList - ), + Selector listRequest, List listReady})>( + selector: (context, vm) => (isLoading: vm.isPatientMedicalReportsListLoading, listRequest: vm.patientMedicalReportRequestedList, listReady: vm.patientMedicalReportReadyList), builder: (context, data, _) { return MedicalReportCard(isLoading: data.isLoading, listRequest: data.listRequest, listReady: data.listReady); }, @@ -1546,17 +1521,11 @@ class _MedicalFilePageState extends State { child: _buildVitalSignCard( icon: AppAssets.bloodPressure, label: LocaleKeys.bloodPressure.tr(context: context), - value: (vitalSign.bloodPressureLower != null && - vitalSign.bloodPressureHigher != null && - vitalSign.bloodPressureLower != 0 && - vitalSign.bloodPressureHigher != 0) + value: (vitalSign.bloodPressureLower != null && vitalSign.bloodPressureHigher != null && vitalSign.bloodPressureLower != 0 && vitalSign.bloodPressureHigher != 0) ? "${vitalSign.bloodPressureHigher}/${vitalSign.bloodPressureLower}" : '--', unit: '', - status: (vitalSign.bloodPressureLower != null && - vitalSign.bloodPressureHigher != null && - vitalSign.bloodPressureLower != 0 && - vitalSign.bloodPressureHigher != 0) + status: (vitalSign.bloodPressureLower != null && vitalSign.bloodPressureHigher != null && vitalSign.bloodPressureLower != 0 && vitalSign.bloodPressureHigher != 0) ? _getBloodPressureStatus( systolic: vitalSign.bloodPressureHigher, diastolic: vitalSign.bloodPressureLower, @@ -1640,8 +1609,7 @@ class _MedicalFilePageState extends State { weight: FontWeight.w600, ), ), - Utils.buildSvgWithAssets( - icon: getIt.get().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward, width: 18.w, height: 18.h), + Utils.buildSvgWithAssets(icon: getIt.get().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward, width: 18.w, height: 18.h), ], ), Spacer(), diff --git a/lib/presentation/my_family/my_family.dart b/lib/presentation/my_family/my_family.dart index d25154c7..4eebc75c 100644 --- a/lib/presentation/my_family/my_family.dart +++ b/lib/presentation/my_family/my_family.dart @@ -92,7 +92,10 @@ class _FamilyMedicalScreenState extends State { Selector patientFiles, List pendingFiles})>( selector: (_, model) => (selectedIndex: model.getSelectedFamilyFileTabIndex, patientFiles: model.patientFamilyFiles, pendingFiles: model.pendingFamilyFiles), shouldRebuild: (previous, next) { - return previous.selectedIndex != next.selectedIndex || previous.patientFiles.length != next.patientFiles.length || previous.pendingFiles.length != next.pendingFiles.length; + // Only rebuild if something actually changed + return previous.selectedIndex != next.selectedIndex || + !identical(previous.patientFiles, next.patientFiles) || + !identical(previous.pendingFiles, next.pendingFiles); }, builder: (context, data, child) => getFamilyTabs(index: data.selectedIndex, patientFiles: data.patientFiles, pendingFiles: data.pendingFiles), ), diff --git a/lib/presentation/my_family/widget/family_cards.dart b/lib/presentation/my_family/widget/family_cards.dart index bfae773f..2f48bcbe 100644 --- a/lib/presentation/my_family/widget/family_cards.dart +++ b/lib/presentation/my_family/widget/family_cards.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -16,11 +14,13 @@ 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/insurance/insurance_view_model.dart'; import 'package:hmg_patient_app_new/features/medical_file/models/family_file_response_model.dart'; +import 'package:hmg_patient_app_new/features/profile_picture/profile_picture_view_model.dart'; import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/presentation/insurance/widgets/insurance_update_details_card.dart'; import 'package:hmg_patient_app_new/services/dialog_service.dart'; import 'package:hmg_patient_app_new/services/navigation_service.dart'; +import 'package:hmg_patient_app_new/services/permission_service.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'; @@ -29,7 +29,6 @@ import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; import 'package:hmg_patient_app_new/widgets/expandable_list_widget.dart'; import 'package:hmg_patient_app_new/widgets/user_avatar_widget.dart'; import 'package:hmg_patient_app_new/widgets/image_picker.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; class FamilyCards extends StatefulWidget { @@ -63,10 +62,9 @@ class FamilyCards extends StatefulWidget { class _FamilyCardsState extends State { AppState appState = getIt(); + final PermissionService _permissionService = getIt(); late InsuranceViewModel insuranceViewModel; late ProfileSettingsViewModel profileSettingsViewModel; - File? _selectedImage; - bool _isUploadingImage = false; @override void initState() { @@ -77,301 +75,27 @@ class _FamilyCardsState extends State { } void _pickImage() { - // Show image picker options without checking permissions first - ImageOptions.showImageOptionsNew( - context, - false, // Don't show files option, only camera and gallery - (base64String, file) async { - try { - // Compress and resize the image - final compressedFile = await ImageCompressionHelper.compressAndResizeImage(file); - - File finalFile; - String finalBase64; - - if (compressedFile == null) { - - // Fallback: use original image if compression fails - final originalSize = await file.length(); - final maxSize = 1048576; // 1MB - - if (originalSize > maxSize) { - if (mounted) { - Utils.showToast( - LocaleKeys.imageSizeTooLarge.tr(context: context), - ); - } - return; - } - finalFile = file; - var bytes = await file.readAsBytes(); - finalBase64 = base64.encode(bytes); - } else { - // Check compressed file size - final fileSize = await compressedFile.length(); - final maxSize = 1048576; // 1MB - - if (fileSize > maxSize) { - if (mounted) { - Utils.showToast( - LocaleKeys.imageSizeTooLarge.tr(context: context), - ); - } - return; - } - - finalFile = compressedFile; - var bytes = await compressedFile.readAsBytes(); - finalBase64 = base64.encode(bytes); - } - - if (mounted) { - setState(() { - _selectedImage = finalFile; - }); - - // Upload the image - _uploadImage(finalBase64); - } - - } catch (e, stackTrace) { - if (mounted) { - Utils.showToast( - LocaleKeys.failedToProcessImage.tr(context: context), - ); - } - } - }, - checkCameraPermission: _checkCameraPermission, - checkGalleryPermission: _checkGalleryPermission, - ); - } - - Future _checkCameraPermission() async { - try { - print('=== Checking camera permission ==='); - - // First check current status - PermissionStatus currentStatus = await Permission.camera.status; - print('Current camera permission status: $currentStatus'); - - // If already granted, return true - if (currentStatus.isGranted) { - print('βœ… Camera permission already granted'); - return true; - } - - // If denied or permanently denied, show settings dialog - if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) { - // Request permission first - PermissionStatus newStatus = await Permission.camera.request(); - print('Camera permission after request: $newStatus'); + final profilePictureViewModel = context.read(); - if (newStatus.isGranted) { - print('βœ… Camera permission granted'); - return true; - } - - // Still denied - show settings dialog - print('⚠️ Camera permission denied - showing settings dialog'); + profilePictureViewModel.pickImage( + context, + showImagePicker: ImageOptions.showImageOptionsNew, + compressImage: ImageCompressionHelper.compressAndResizeImage, + onSuccess: (data) { if (mounted) { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context), - isShowActionButtons: true, - onCancelTap: () { - Navigator.pop(context); - }, - onConfirmTap: () async { - openAppSettings(); - }, - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); + Utils.showToast(LocaleKeys.profileImageUpdatedSuccessfully.tr(context: context)); } - return false; - } - - // Request permission for the first time - PermissionStatus newStatus = await Permission.camera.request(); - print('Camera permission after request: $newStatus'); - - if (newStatus.isGranted) { - print('βœ… Camera permission granted'); - return true; - } - - // Denied - show settings dialog - print('❌ Camera permission denied - showing settings dialog'); - if (mounted) { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context), - isShowActionButtons: true, - onCancelTap: () { - Navigator.pop(context); - }, - onConfirmTap: () async { - openAppSettings(); - }, - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } - return false; - } catch (e) { - print('❌ Error checking camera permission: $e'); - if (mounted) { - Utils.showToast( - LocaleKeys.failedToCheckPermissions.tr(context: context), - ); - } - return false; - } - } - - Future _checkGalleryPermission() async { - try { - print('=== Checking gallery permission ==='); - - // Determine which permission to check based on platform and Android version - Permission galleryPermission; - - if (Platform.isIOS) { - galleryPermission = Permission.photos; - } else { - // Android: use photos permission which handles API level differences automatically - print('βœ… Android Photo Picker will handle permissions internally'); - return true; - } - - // First check current status - PermissionStatus currentStatus = await galleryPermission.status; - print('Current gallery permission status: $currentStatus'); - - // If already granted, return true - if (currentStatus.isGranted || currentStatus.isLimited) { - print('βœ… Gallery permission already granted'); - return true; - } - - // If denied or permanently denied, request permission first - if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) { - // Request permission first - PermissionStatus newStatus = await galleryPermission.request(); - print('Gallery permission after request: $newStatus'); - - if (newStatus.isGranted || newStatus.isLimited) { - print('βœ… Gallery permission granted'); - return true; - } - - // Still denied - show settings dialog - print('⚠️ Gallery permission denied - showing settings dialog'); + }, + onError: (error) { if (mounted) { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context), - isShowActionButtons: true, - onCancelTap: () { - Navigator.pop(context); - }, - onConfirmTap: () async { - openAppSettings(); - }, - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } - return false; - } - - // Request permission for the first time - PermissionStatus newStatus = await galleryPermission.request(); - print('Gallery permission after request: $newStatus'); - - if (newStatus.isGranted || newStatus.isLimited) { - print('βœ… Gallery permission granted'); - return true; - } - - // Denied - show settings dialog - print('❌ Gallery permission denied - showing settings dialog'); - if (mounted) { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context), - isShowActionButtons: true, - onCancelTap: () { - Navigator.pop(context); - }, - onConfirmTap: () async { - openAppSettings(); - }, - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } - return false; - } catch (e) { - print('❌ Error checking gallery permission: $e'); - if (mounted) { - Utils.showToast( - LocaleKeys.failedToCheckPermissions.tr(context: context), - ); - } - return false; - } - } - - void _uploadImage(String base64String) { - final patientID = appState.getAuthenticatedUser()?.patientId; - - if (patientID != null) { - setState(() { - _isUploadingImage = true; - }); - - profileSettingsViewModel.uploadProfileImage( - patientID: patientID, - imageData: base64String, - onSuccess: (data) { - if (mounted) { - setState(() { - _selectedImage = null; // Clear selected image after successful upload - _isUploadingImage = false; - }); - Utils.showToast( - LocaleKeys.profileImageUpdatedSuccessfully.tr(context: context), - ); - } - }, - onError: (error) { - if (mounted) { - setState(() { - _isUploadingImage = false; - }); - } Utils.showToast(error); - }, - ); - } + } + }, + imageSizeTooLargeMessage: LocaleKeys.imageSizeTooLarge.tr(context: context), + failedToProcessImageMessage: LocaleKeys.failedToProcessImage.tr(context: context), + checkCameraPermission: (ctx) => _permissionService.checkCameraPermission(ctx), + checkGalleryPermission: (ctx) => _permissionService.checkGalleryPermission(ctx), + ); } double _calculateAspectRatio(BuildContext context) { @@ -537,40 +261,44 @@ class _FamilyCardsState extends State { Positioned( right: 0, bottom: 0, - child: GestureDetector( - onTap: () { - if (!_isUploadingImage) { - _pickImage(); - } - }, - child: Container( - width: 20.w, - height: 20.h, - decoration: BoxDecoration( - color: AppColors.primaryRedColor, - shape: BoxShape.circle, - border: Border.all( - color: AppColors.whiteColor, - width: 1.5.w, - ), - ), - child: _isUploadingImage - ? SizedBox( - width: 10.w, - height: 10.h, - child: CircularProgressIndicator( - strokeWidth: 1.5.w, - valueColor: AlwaysStoppedAnimation( - AppColors.whiteColor, - ), - ).paddingAll(4.w), - ) - : Icon( - Icons.camera_alt, + child: Consumer( + builder: (context, profileVm, _) { + return GestureDetector( + onTap: () { + if (!profileVm.isUploadingProfileImage) { + _pickImage(); + } + }, + child: Container( + width: 20.w, + height: 20.h, + decoration: BoxDecoration( + color: AppColors.primaryRedColor, + shape: BoxShape.circle, + border: Border.all( color: AppColors.whiteColor, - size: 10.w, + width: 1.5.w, ), - ), + ), + child: profileVm.isUploadingProfileImage + ? SizedBox( + width: 10.w, + height: 10.h, + child: CircularProgressIndicator( + strokeWidth: 1.5.w, + valueColor: AlwaysStoppedAnimation( + AppColors.whiteColor, + ), + ).paddingAll(4.w), + ) + : Icon( + Icons.camera_alt, + color: AppColors.whiteColor, + size: 10.w, + ), + ), + ); + }, ), ), ], diff --git a/lib/presentation/offers_and_discounts/offer_and_discounts_full_screen_swiper_page.dart b/lib/presentation/offers_and_discounts/offer_and_discounts_full_screen_swiper_page.dart new file mode 100644 index 00000000..8e7a53cd --- /dev/null +++ b/lib/presentation/offers_and_discounts/offer_and_discounts_full_screen_swiper_page.dart @@ -0,0 +1,262 @@ +import 'package:flutter/cupertino.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'; +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/theme/colors.dart'; +import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; +import 'package:share_plus/share_plus.dart'; + +class OfferAndDiscountsFullScreenSwiperPage extends StatefulWidget { + final List? images; + final int initialIndex; + + const OfferAndDiscountsFullScreenSwiperPage({ + super.key, + this.images, + this.initialIndex = 0, + }); + + @override + State createState() => _OfferAndDiscountsFullScreenSwiperPageState(); +} + +class _OfferAndDiscountsFullScreenSwiperPageState extends State { + late PageController _pageController; + late int _currentPage; + late List _promoImages; + + @override + void initState() { + super.initState(); + // Use provided images or default demo images + _promoImages = widget.images ?? + [ + 'assets/images/offersanddiscounts/promo.jpg', + 'assets/images/offersanddiscounts/promo.jpg', + 'assets/images/offersanddiscounts/promo.jpg', + 'assets/images/offersanddiscounts/promo.jpg', + ]; + _currentPage = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFADA6D0), + body: SafeArea( + child: Stack( + children: [ + // Main content area + Column( + children: [ + SizedBox(height: 26.h), + + // Page indicators + _buildPageIndicators(), + + SizedBox(height: 70.h), + + // Swipeable image section + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentPage = index; + }); + }, + itemCount: _promoImages.length, + itemBuilder: (context, index) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 0.h), + child: Transform.flip( + flipX: getIt.get().isArabic(), + child: ClipRRect( + borderRadius: BorderRadius.circular(16.r), + child: Image.asset( + _promoImages[index], + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16.r), + ), + child: Center( + child: Icon( + Icons.image_outlined, + size: 100.h, + color: Colors.white.withValues(alpha: 0.5), + ), + ), + ); + }, + ), + ), + ), + ), + ); + }, + ), + ), + + SizedBox(height: 70.h), + // Bottom action buttons + _buildBottomActions(), + + SizedBox(height: 12.h), + ], + ), + + // Close button (top right) + Positioned( + top: 26.h + 14.h, // After indicator spacing + right: 24.w, + child: _buildCloseButton()), + ], + ), + ), + ); + } + + Widget _buildPageIndicators() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Row( + children: List.generate( + _promoImages.length, + (index) => Expanded( + child: Container( + margin: EdgeInsets.only( + right: index < _promoImages.length - 1 ? 4.w : 0, + ), + height: 4.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2.r), + // Fill bar if it's current page or any previous page + color: index <= _currentPage ? Colors.white : const Color(0x33000000), // 20% opacity + ), + ), + ), + ), + ), + ); + } + + Widget _buildCloseButton() { + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + child: Container( + width: 32.w, + height: 32.h, + decoration: BoxDecoration( + color: const Color(0x33FFFFFF), // 20% opacity + borderRadius: BorderRadius.circular(8.r), + ), + child: Center( + child: Icon( + Icons.close, + color: AppColors.textColor, + size: 20.h, + ), + ), + ), + ); + } + + Widget _buildBottomActions() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Row( + children: [ + // Buy Now button (takes remaining space) + Expanded( + child: CustomButton( + text: 'Buy Now', + icon: AppAssets.shoppingCart, + iconColor: AppColors.whiteColor, + onPressed: () { + // Handle buy now action + }, + backgroundColor: AppColors.primaryRedColor, + borderColor: AppColors.primaryRedColor, + textColor: AppColors.whiteColor, + fontSize: 16.f, + isBold: true, + borderRadius: 12.r, + height: 56.h, + ), + ), + + SizedBox(width: 12.w), + + // Next button + _buildIconButton( + svgIcon: AppAssets.nextSwiper, + onTap: () { + if (_currentPage < _promoImages.length - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + ), + + SizedBox(width: 12.w), + + _buildIconButton( + svgIcon: AppAssets.share, + onTap: () async { + await Share.share('Check out this amazing offer!'); + }), + ], + ), + ); + } + + Widget _buildIconButton({ + IconData? icon, + String? svgIcon, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 56.w, + height: 56.h, + decoration: BoxDecoration( + color: const Color(0x57303957), // 34% opacity approximation + borderRadius: BorderRadius.circular(12.r), + ), + child: Center( + child: svgIcon != null + ? Utils.buildSvgWithAssets( + icon: svgIcon, + iconColor: AppColors.whiteColor, + width: 24.h, + height: 24.h, + ) + : Icon( + icon!, + color: AppColors.whiteColor, + size: 24.h, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart b/lib/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart new file mode 100644 index 00000000..86dedbcd --- /dev/null +++ b/lib/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart @@ -0,0 +1,223 @@ +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/utils/size_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/offers_and_discounts/models/offers_and_discounts_response_model.dart'; +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'; + +class OffersAndDiscountsDetailedPage extends StatelessWidget { + final OffersAndDiscountsResponseModel offer; + + const OffersAndDiscountsDetailedPage({ + super.key, + required this.offer, + }); + + String _formatDateString(String? dateString) { + if (dateString == null || dateString.isEmpty) return ''; + try { + DateTime date = DateTime.parse(dateString); + return DateFormat('d MMM, yyyy').format(date); + } catch (e) { + return dateString; + } + } + + // Determine the status of the offer based on dates and isActive flag + String _getOfferStatus(String? endDate, bool? isActive) { + if (endDate == null || endDate.isEmpty) return 'Expired'; + try { + DateTime end = DateTime.parse(endDate); + DateTime now = DateTime.now(); + + if (now.isAfter(end)) { + return 'Expired'; + } else { + if (isActive == true) { + return 'Active'; + } else { + return 'Availed'; + } + } + } catch (e) { + return 'Expired'; + } + } + + Color _getStatusBgColor(String status) { + switch (status) { + case 'Active': + return AppColors.successColor.withValues(alpha: 0.1); + case 'Availed': + return AppColors.infoColor.withValues(alpha: 0.1); + case 'Expired': + return AppColors.errorColor.withValues(alpha: 0.1); + default: + return AppColors.greyColor; + } + } + + Color _getStatusTextColor(String status) { + switch (status) { + case 'Active': + return AppColors.successColor; + case 'Availed': + return AppColors.infoColor; + case 'Expired': + return AppColors.errorColor; + default: + return AppColors.textColor; + } + } + + @override + Widget build(BuildContext context) { + final status = _getOfferStatus(offer.endDate, offer.isActive); + + return CollapsingListView( + title: "${LocaleKeys.offers.tr(context: context)} ${LocaleKeys.details.tr(context: context)}", + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Offer Image + if (offer.imageUrl != null) + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(24.h), + child: Image.network( + offer.imageUrl!, + width: double.infinity, + height: 250.h, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 250.h, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.greyColor, + borderRadius: 24.h, + ), + child: Center( + child: Icon(Icons.image_not_supported, size: 64.h), + ), + ); + }, + ), + ), + // Discount badge at top right on image + // if (offer.discount != null) + // Positioned( + // top: 16.h, + // right: 16.w, + // child: Container( + // padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + // decoration: BoxDecoration( + // color: AppColors.primaryRedColor, + // borderRadius: BorderRadius.circular(8.r), + // ), + // child: Text( + // offer.discount!, + // style: TextStyle( + // fontSize: 14.f, + // fontWeight: FontWeight.w700, + // color: AppColors.whiteColor, + // ), + // ), + // ), + // ), + ], + ), + + SizedBox(height: 24.h), + + // Title + if (offer.title != null) + offer.title!.toText24( + isBold: true, + fontWeight: FontWeight.w700, + ), + + SizedBox(height: 16.h), + + // Chips Row (Valid Till and Status) + Wrap( + spacing: 12.w, + runSpacing: 12.h, + children: [ + // Valid till chip + if (offer.endDate != null) + AppCustomChipWidget( + labelText: "Valid till ${_formatDateString(offer.endDate)}", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.calendar, + iconSize: 14.h, + iconColor: AppColors.textColor, + isEnglishOnly: true, + ), + // Status chip + AppCustomChipWidget( + labelText: status, + backgroundColor: _getStatusBgColor(status), + textColor: _getStatusTextColor(status), + ), + // Facility type chip + if (offer.facilityType != null) + AppCustomChipWidget( + labelText: offer.facilityType!, + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + ), + ], + ), + + SizedBox(height: 32.h), + + // Description Heading + LocaleKeys.description.tr(context: context).toText18( + isBold: true, + weight: FontWeight.w700, + ), + + SizedBox(height: 12.h), + + // Description Content + if (offer.description != null) + offer.description!.toText16( + color: AppColors.greyTextColor, + height: 1.5, + ), + + SizedBox(height: 32.h), + + // Buy Now Button + CustomButton( + text: "Buy Now", + icon: AppAssets.shoppingCart, + iconColor: AppColors.whiteColor, + onPressed: () { + // Handle buy now action + }, + backgroundColor: AppColors.primaryRedColor, + borderColor: AppColors.primaryRedColor, + textColor: AppColors.whiteColor, + fontSize: 16.f, + isBold: true, + borderRadius: 12.r, + height: 56.h, + ), + + SizedBox(height: 24.h), + ], + ).paddingSymmetrical(24.h, 0.h), + ), + ); + } +} diff --git a/lib/presentation/offers_and_discounts/offers_and_discounts_history_page.dart b/lib/presentation/offers_and_discounts/offers_and_discounts_history_page.dart new file mode 100644 index 00000000..74ef2426 --- /dev/null +++ b/lib/presentation/offers_and_discounts/offers_and_discounts_history_page.dart @@ -0,0 +1,292 @@ +import 'dart:async'; + +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/offers_and_discounts/offers_and_discounts_view_model.dart'; +import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offers_and_discounts_detailed_page.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/routes/custom_page_route.dart'; +import 'package:provider/provider.dart'; + +class OffersAndDiscountsHistoryPage extends StatefulWidget { + const OffersAndDiscountsHistoryPage({super.key}); + + @override + State createState() => _OffersAndDiscountsHistoryPageState(); +} + +class _OffersAndDiscountsHistoryPageState extends State { + late OffersAndDiscountsViewModel offersAndDiscountsViewModel; + late AppState appState; + + @override + void initState() { + scheduleMicrotask(() { + offersAndDiscountsViewModel.getOffersAndDiscountsHistory(); + }); + super.initState(); + } + + String _formatDateString(String? dateString) { + if (dateString == null || dateString.isEmpty) return ''; + try { + DateTime date = DateTime.parse(dateString); + return DateFormat('d MMM, yyyy').format(date); + } catch (e) { + return dateString; + } + } + + // Determine the status of the offer based on dates and isActive flag + String _getOfferStatus(String? endDate, bool? isActive) { + if (endDate == null || endDate.isEmpty) return 'Expired'; + try { + DateTime end = DateTime.parse(endDate); + DateTime now = DateTime.now(); + + if (now.isAfter(end)) { + return 'Expired'; + } else { + // If end date is in future, check isActive flag + if (isActive == true) { + return 'Active'; + } else { + return 'Availed'; + } + } + } catch (e) { + return 'Expired'; + } + } + + Color _getStatusBgColor(String status) { + switch (status) { + case 'Active': + return AppColors.successColor.withValues(alpha: 0.1); + case 'Availed': + return AppColors.infoColor.withValues(alpha: 0.1); + case 'Expired': + return AppColors.errorColor.withValues(alpha: 0.1); + default: + return AppColors.greyColor; + } + } + + Color _getStatusTextColor(String status) { + switch (status) { + case 'Active': + return AppColors.successColor; + case 'Availed': + return AppColors.infoColor; + case 'Expired': + return AppColors.errorColor; + default: + return AppColors.textColor; + } + } + + @override + Widget build(BuildContext context) { + appState = getIt.get(); + offersAndDiscountsViewModel = Provider.of(context, listen: false); + + return CollapsingListView( + title: '${LocaleKeys.order.tr(context: context)} ${LocaleKeys.history.tr(context: context)}', + child: SingleChildScrollView( + child: Consumer(builder: (context, offersAndDiscountVM, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + offersAndDiscountVM.isHistoryLoading + ? Container( + height: 200.h, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.h, + hasShadow: true, + ), + child: Center(child: Utils.getLoadingWidget()), + ).paddingSymmetrical(24.h, 0.h) + : ListView.separated( + padding: EdgeInsets.only(top: 12.h), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: offersAndDiscountVM.filteredHistory.isNotEmpty ? offersAndDiscountVM.filteredHistory.length : 1, + itemBuilder: (context, index) { + if (offersAndDiscountVM.filteredHistory.isEmpty) { + return Utils.getNoDataWidget(context, noDataText: LocaleKeys.noDataAvailable.tr(context: context)); + } + + final offer = offersAndDiscountVM.filteredHistory[index]; + final status = _getOfferStatus(offer.endDate, offer.isActive); + + return 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: Stack( + children: [ + Padding( + padding: EdgeInsets.all(16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title with space for status chip + Padding( + padding: EdgeInsets.only(right: 80.w), + child: offer.title?.toText18( + isBold: true, + weight: FontWeight.w700, + ) ?? + SizedBox(), + ), + SizedBox(height: 12.h), + + // Info chips + Wrap( + spacing: 8.w, + runSpacing: 8.h, + children: [ + // Start Date chip + if (offer.startDate != null) + AppCustomChipWidget( + labelText: _formatDateString(offer.startDate), + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.calendar, + iconSize: 12.h, + iconColor: AppColors.textColor, + isEnglishOnly: true, + ), + // Hospital/Facility chip + if (offer.facilityType != null) + AppCustomChipWidget( + labelText: offer.facilityType!, + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + ), + // Purchase date chip (using startDate as purchased date) + if (offer.startDate != null) + AppCustomChipWidget( + labelText: "Purchased ${_formatDateString(offer.startDate)}", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + isEnglishOnly: true, + ), + // Valid until chip + if (offer.endDate != null) + AppCustomChipWidget( + labelText: "Valid till ${_formatDateString(offer.endDate)}", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.calendar, + iconSize: 12.h, + iconColor: AppColors.textColor, + isEnglishOnly: true, + ), + // Order ID chip (using offer.id) + if (offer.id != null) + AppCustomChipWidget( + labelText: "ID: #${offer.id}", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + isEnglishOnly: true, + ), + ], + ), + SizedBox(height: 16.h), + + // Action buttons + Row( + children: [ + Expanded( + child: CustomButton( + text: 'View Offer Details', + onPressed: () { + Navigator.of(context).push( + CustomPageRoute( + page: OffersAndDiscountsDetailedPage( + offer: offersAndDiscountVM.filteredOffers[index], + ), + ), + ); + }, + backgroundColor: AppColors.errorColor.withValues(alpha: 0.1), + borderColor: Colors.transparent, + textColor: AppColors.errorColor, + fontSize: 12.f, + isBold: true, + borderRadius: 12.r, + height: 40.h, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: CustomButton( + text: 'View Order Details', + onPressed: () { + // Handle view order details + }, + backgroundColor: AppColors.whiteColor, + borderColor: AppColors.textColor, + textColor: AppColors.textColor, + fontSize: 12.f, + isBold: true, + borderRadius: 12.r, + height: 40.h, + ), + ), + ], + ), + ], + ), + ), + // Status chip at top right + Positioned( + top: 16.h, + right: 16.w, + child: AppCustomChipWidget( + labelText: status, + backgroundColor: _getStatusBgColor(status), + textColor: _getStatusTextColor(status), + ), + ), + ], + ), + ).paddingSymmetrical(24.h, 0.h), + ), + ), + ); + }, + separatorBuilder: (BuildContext cxt, int index) => SizedBox(height: 16.h), + ), + SizedBox(height: 24.h), + ], + ); + }), + ), + ); + } +} diff --git a/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart b/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart new file mode 100644 index 00000000..a38c7935 --- /dev/null +++ b/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart @@ -0,0 +1,279 @@ +import 'dart:async'; + +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/enums.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/widget_extensions.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_view_model.dart'; +import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart'; +import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offers_and_discounts_history_page.dart'; +import 'package:hmg_patient_app_new/presentation/offers_and_discounts/widgets/offers_and_discount_type_selection_widget.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/routes/custom_page_route.dart'; +import 'package:provider/provider.dart'; + +import '../../widgets/input_widget.dart'; + +class OffersAndDiscountsPage extends StatefulWidget { + const OffersAndDiscountsPage({super.key}); + + @override + State createState() => _OffersAndDiscountsPageState(); +} + +class _OffersAndDiscountsPageState extends State { + late OffersAndDiscountsViewModel offersAndDiscountsViewModel; + late AppState appState; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + scheduleMicrotask(() { + offersAndDiscountsViewModel.initOffersAndDiscounts(); + }); + super.initState(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + + @override + Widget build(BuildContext context) { + appState = getIt.get(); + offersAndDiscountsViewModel = Provider.of(context, listen: false); + + return CollapsingListView( + title: "${LocaleKeys.offers.tr(context: context)} & ${LocaleKeys.discount.tr(context: context)}", + history: () { + Navigator.of(context).push( + CustomPageRoute( + page: OffersAndDiscountsHistoryPage(), + ), + ); + }, + child: SingleChildScrollView( + child: Consumer(builder: (context, offersAndDiscountVM, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search Input + TextInputWidget( + labelText: LocaleKeys.search.tr(context: context), + hintText: LocaleKeys.search.tr(context: context), + controller: _searchController, + onChange: (value) { + offersAndDiscountVM.setSearchQuery(value!); + }, + isEnable: true, + prefix: null, + autoFocus: false, + isBorderAllowed: false, + keyboardType: TextInputType.text, + isAllowLeadingIcon: true, + selectionType: SelectionTypeEnum.search, + padding: EdgeInsets.symmetric( + vertical: ResponsiveExtension(10).h, + horizontal: ResponsiveExtension(15).h, + ), + ), + SizedBox(height: 16.h), + OffersAndDiscountTypeSelectionWidget( + selectedOffers: offersAndDiscountVM.selectedFacilities, + onOfferClicked: (selectedValues) { + offersAndDiscountVM.setSelectedFacility(selectedValues); + }, + ), + + SizedBox(height: 16.h), + + // Offers Grid + offersAndDiscountVM.isOffersLoading + ? GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 24.w), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16.w, + mainAxisSpacing: 16.h, + childAspectRatio: 0.55, // Adjust this to control card height + ), + itemCount: 6, + // Show 6 loading placeholders + itemBuilder: (context, index) { + return Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.h, + hasShadow: true, + ), + child: Center(child: Utils.getLoadingWidget()), + ); + }, + ) + : offersAndDiscountVM.filteredOffers.isNotEmpty + ? GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 0.w), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2, crossAxisSpacing: 16.w, mainAxisSpacing: 16.h, childAspectRatio: 0.55), + itemCount: offersAndDiscountVM.filteredOffers.length, + itemBuilder: (context, index) { + return AnimationConfiguration.staggeredGrid( + position: index, + duration: const Duration(milliseconds: 500), + columnCount: 2, + child: ScaleAnimation( + child: FadeInAnimation( + child: AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.h, + hasShadow: true, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (offersAndDiscountVM.filteredOffers[index].imageUrl != null) + Expanded( + flex: 3, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(24.h)), + child: SizedBox.expand( + child: Image.network( + offersAndDiscountVM.filteredOffers[index].imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: AppColors.greyColor, + child: Center( + child: Icon(Icons.image_not_supported, size: 32.h), + ), + ); + }, + ), + ), + ).onPress(() { + Navigator.of(context).push( + CustomPageRoute( + page: OffersAndDiscountsDetailedPage( + offer: offersAndDiscountVM.filteredOffers[index], + ), + ), + ); + }), + // Discount badge at top right + // if (offersAndDiscountVM.filteredOffers[index].discount != null) + // Positioned( + // top: 8.h, + // right: 8.w, + // child: Container( + // padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + // decoration: BoxDecoration( + // color: AppColors.primaryRedColor, + // borderRadius: BorderRadius.circular(6.r), + // ), + // child: Text( + // offersAndDiscountVM.filteredOffers[index].discount!, + // style: TextStyle( + // fontSize: 10.f, + // fontWeight: FontWeight.w700, + // color: AppColors.whiteColor, + // ), + // ), + // ), + // ), + ], + ), + ), + Expanded( + flex: 3, + child: Padding( + padding: EdgeInsets.all(12.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (offersAndDiscountVM.filteredOffers[index].title != null) + Text( + offersAndDiscountVM.filteredOffers[index].title!, + style: TextStyle( + fontSize: 14.f, + fontWeight: FontWeight.w700, + color: AppColors.textColor, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 8.h), + // Valid till chip + if (offersAndDiscountVM.filteredOffers[index].endDate != null) + AppCustomChipWidget( + labelText: "Valid till ${Utils.formatDateToDisplay(offersAndDiscountVM.filteredOffers[index].endDate ?? "")}", + // labelText: "Valid till ${_formatDateString(offersAndDiscountVM.filteredOffers[index].endDate)}", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.calendar, + iconSize: 12.h, + iconColor: AppColors.textColor, + isEnglishOnly: true, + labelPadding: EdgeInsetsDirectional.only(start: 4.w, end: 8.w), + ), + SizedBox(height: 20.h), + CustomButton( + text: 'Buy Now', + icon: AppAssets.shoppingCart, + iconColor: AppColors.whiteColor, + onPressed: () { + // Handle buy now action + }, + backgroundColor: AppColors.primaryRedColor, + borderColor: AppColors.primaryRedColor, + textColor: AppColors.whiteColor, + fontSize: 14.f, + isBold: true, + borderRadius: 12.r, + height: 40.h, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ) + : Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Utils.getNoDataWidget(context, noDataText: LocaleKeys.noDataAvailable.tr(context: context)), + ), + SizedBox(height: 24.h), + ], + ).paddingSymmetrical(24.h, 0.h); + }), + ), + ); + } +} diff --git a/lib/presentation/offers_and_discounts/widgets/offers_and_discount_type_selection_widget.dart b/lib/presentation/offers_and_discounts/widgets/offers_and_discount_type_selection_widget.dart new file mode 100644 index 00000000..11713003 --- /dev/null +++ b/lib/presentation/offers_and_discounts/widgets/offers_and_discount_type_selection_widget.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/utils/size_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/theme/colors.dart'; + +class OffersAndDiscountTypeSelectionWidget extends StatelessWidget { + final List selectedOffers; + final Function(List) onOfferClicked; + + const OffersAndDiscountTypeSelectionWidget({ + super.key, + required this.selectedOffers, + required this.onOfferClicked, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildOfferTypeCard( + context: context, + title: "All Offers", + facilityType: 'All Offers', + isSelected: selectedOffers.contains('All Offers'), + ), + SizedBox(width: 12.w), + _buildOfferTypeCard( + context: context, + title: 'Female', + facilityType: 'Female', + isSelected: selectedOffers.contains('Female'), + ), + SizedBox(width: 12.w), + _buildOfferTypeCard( + context: context, + title: 'OB-Gyne', + facilityType: 'OB-Gyne', + isSelected: selectedOffers.contains('OB-Gyne'), + ), + SizedBox(width: 12.w), + _buildOfferTypeCard( + context: context, + title: 'Dermatology', + facilityType: 'Dermatology', + isSelected: selectedOffers.contains('Dermatology'), + ), + SizedBox(width: 12.w), + _buildOfferTypeCard( + context: context, + title: 'Radiology', + facilityType: 'Radiology', + isSelected: selectedOffers.contains('Radiology'), + ), + ], + ), + ); + } + + Widget _buildOfferTypeCard({ + required BuildContext context, + required String title, + required String facilityType, + required bool isSelected, + }) { + return AnimatedContainer( + duration: Duration(milliseconds: 200), + padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 16.w), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: isSelected ? AppColors.bgRedLightColor : AppColors.whiteColor, + borderRadius: 12.r, + hasShadow: true, + side: isSelected ? BorderSide(color: AppColors.primaryRedColor, width: 2) : BorderSide(color: AppColors.borderGrayColor, width: 1), + ), + child: Center( + child: title.toText14( + color: isSelected ? AppColors.primaryRedColor : AppColors.textColor, + weight: isSelected ? FontWeight.w700 : FontWeight.w500, + isBold: isSelected, + ), + ), + ).onPress(() { + List updatedSelection = List.from(selectedOffers); + + if (facilityType == 'All Offers') { + if (updatedSelection.contains('All Offers')) { + updatedSelection.clear(); + } else { + updatedSelection.clear(); + updatedSelection.add('All Offers'); + } + } else { + // Remove "All Offers" if any specific type is selected + updatedSelection.remove('All Offers'); + + // Toggle the clicked facility type + if (updatedSelection.contains(facilityType)) { + updatedSelection.remove(facilityType); + // If no selection left, default to "All Offers" + if (updatedSelection.isEmpty) { + updatedSelection.add('All Offers'); + } + } else { + updatedSelection.add(facilityType); + } + } + + onOfferClicked(updatedSelection); + }); + } +} diff --git a/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart b/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart new file mode 100644 index 00000000..cfd6a86e --- /dev/null +++ b/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; +import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offer_and_discounts_full_screen_swiper_page.dart'; +import 'package:hmg_patient_app_new/theme/colors.dart'; +import 'package:smooth_corner/smooth_corner.dart'; + +class OffersAndDiscountsCarousel extends StatefulWidget { + const OffersAndDiscountsCarousel({super.key}); + + @override + State createState() => _OffersAndDiscountsCarouselState(); +} + +class _OffersAndDiscountsCarouselState extends State { + final ScrollController _scrollController = ScrollController(); + Timer? _autoScrollTimer; + + // List of offer images - you can add more images here + final List offerImages = [ + 'assets/images/offersanddiscounts/img1.png', + 'assets/images/offersanddiscounts/img2.png', + 'assets/images/offersanddiscounts/img3.png', + 'assets/images/offersanddiscounts/img1.png', + 'assets/images/offersanddiscounts/img2.png', + 'assets/images/offersanddiscounts/img3.png', + 'assets/images/offersanddiscounts/img1.png', + ]; + + @override + void initState() { + super.initState(); + // _startAutoScroll(); + } + + void _startAutoScroll() { + _autoScrollTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) { + if (_scrollController.hasClients) { + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + final delta = 1.0; // Scroll speed + + if (currentScroll >= maxScroll) { + // Reset to beginning for infinite scroll effect + _scrollController.jumpTo(0); + } else { + _scrollController.animateTo( + currentScroll + delta, + duration: const Duration(milliseconds: 50), + curve: Curves.linear, + ); + } + } + }); + } + + @override + void dispose() { + _autoScrollTimer?.cancel(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 79.h, + child: ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: 0.w), + itemCount: offerImages.length * 100, + itemBuilder: (context, index) { + final imageIndex = index % offerImages.length; + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const OfferAndDiscountsFullScreenSwiperPage()), + ); + }, + child: _buildOfferItem(offerImages[imageIndex]), + ); + }, + separatorBuilder: (context, index) => SizedBox(width: 12.w), + ), + ); + } + + Widget _buildOfferItem(String imagePath) { + return Container( + width: 79.w, + height: 79.h, + decoration: ShapeDecoration( + shape: SmoothRectangleBorder( + borderRadius: BorderRadius.circular(13.r), + smoothness: 0.6, + side: BorderSide( + color: Color(0xff2E3039), + width: 2.w, + ), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(13.r), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: AppColors.textColor, width: 2.w), + borderRadius: BorderRadius.circular(11.r), + ), + child: Padding( + padding: const EdgeInsets.all(2.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(11.r), + child: Image.asset( + imagePath, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: AppColors.greyColor, + child: Icon( + Icons.image, + color: AppColors.textColorLight, + size: 32.h, + ), + ); + }, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/profile_settings/widgets/profile_picture_widget.dart b/lib/presentation/profile_settings/widgets/profile_picture_widget.dart index 6b30c0fc..06cf3a8f 100644 --- a/lib/presentation/profile_settings/widgets/profile_picture_widget.dart +++ b/lib/presentation/profile_settings/widgets/profile_picture_widget.dart @@ -1,10 +1,6 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; import 'package:hmg_patient_app_new/core/app_export.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; @@ -12,12 +8,12 @@ import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:hmg_patient_app_new/core/utils/image_compression_helper.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; +import 'package:hmg_patient_app_new/features/profile_picture/profile_picture_view_model.dart'; import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/services/permission_service.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/widgets/image_picker.dart'; -import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; class ProfilePictureWidget extends StatefulWidget { @@ -29,467 +25,63 @@ class ProfilePictureWidget extends StatefulWidget { class _ProfilePictureWidgetState extends State { final AppState _appState = getIt.get(); - File? _selectedImage; - int? _currentPatientId; - bool _isInitialLoadTriggered = false; - - /// Cache decoded image bytes to avoid decoding base64 on every rebuild - Uint8List? _cachedImageBytes; - String? _cachedImageDataHash; + final PermissionService _permissionService = getIt.get(); @override void initState() { super.initState(); - _currentPatientId = _appState.getAuthenticatedUser()?.patientId; - - // Pre-cache existing image data if available (prevents blink from default β†’ loaded) - _tryCacheExistingImage(); - // Use addPostFrameCallback to ensure widget is built before loading + // Initialize view model WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _isInitialLoadTriggered) return; - _isInitialLoadTriggered = true; - - final patientID = _appState.getAuthenticatedUser()?.patientId; - - if (patientID == null) { - print('⚠️ No authenticated user found'); - return; - } - - // Load fresh data from API - print('πŸ“₯ Loading fresh profile image from API for patient: $patientID'); - _loadProfileImage(forceRefresh: false); + if (!mounted) return; + final profilePictureViewModel = context.read(); + profilePictureViewModel.initialize(); + profilePictureViewModel.triggerInitialLoad(); }); } @override void didChangeDependencies() { super.didChangeDependencies(); - // Only check for user switch, NOT on initial load (initState handles that) - if (_isInitialLoadTriggered) { - _checkAndUpdateUserImage(); + // Check for user switch + final profilePictureViewModel = context.read(); + if (profilePictureViewModel.isInitialLoadTriggered) { + profilePictureViewModel.checkForUserSwitch(); } } @override void didUpdateWidget(ProfilePictureWidget oldWidget) { super.didUpdateWidget(oldWidget); - _checkAndUpdateUserImage(); - } - - /// Pre-cache already-loaded image bytes so we don't flash default avatar - void _tryCacheExistingImage() { - final imageData = _appState.getProfileImageData; - if (imageData != null && imageData.isNotEmpty) { - try { - _cachedImageBytes = base64Decode(imageData); - _cachedImageDataHash = '${imageData.length}_${imageData.hashCode}'; - } catch (_) { - _cachedImageBytes = null; - _cachedImageDataHash = null; - } - } - } - - void _checkAndUpdateUserImage() { - // Check if the authenticated user has changed (family member switch) - final currentPatientId = _appState.getAuthenticatedUser()?.patientId; - - if (currentPatientId != null && currentPatientId != _currentPatientId) { - print('πŸ”„ User switched detected: $_currentPatientId -> $currentPatientId'); - - // Update patient ID IMMEDIATELY before any other operations - final oldPatientId = _currentPatientId; - _currentPatientId = currentPatientId; - - // Clear the old profile image data from BOTH AppState and ViewModel - try { - final profileVm = context.read(); - - print('🧹 Clearing cache for old user: $oldPatientId'); - - // Clear AppState cache first - _appState.clearProfileImageCache(); - - // Then clear ViewModel cache - profileVm.clearProfileImageCache(); - - // Clear local decoded bytes cache - _cachedImageBytes = null; - _cachedImageDataHash = null; - - // Force rebuild to show default avatar immediately - if (mounted) { - setState(() { - _selectedImage = null; // Clear any selected image - }); - } - - print('πŸ“₯ Loading profile image for new user: $currentPatientId'); - // Load the new user's profile image immediately - profileVm.getProfileImage( - patientID: currentPatientId, - forceRefresh: true, - onSuccess: (data) { - print('βœ… Profile image loaded successfully for user: $currentPatientId'); - if (mounted) { - _tryCacheExistingImage(); - setState(() {}); // Force rebuild to show new data - } - }, - onError: (error) { - print('❌ Error loading profile image: $error'); - if (mounted) { - setState(() {}); // Force rebuild to show default avatar - } - }, - ); - } catch (e) { - print('❌ Error in _checkAndUpdateUserImage: $e'); - } - } - } - - void _loadProfileImage({bool forceRefresh = false}) { - // Check if profile image is already loaded in AppState (skip if forcing refresh) - if (!forceRefresh && _appState.getProfileImageData != null && _appState.getProfileImageData!.isNotEmpty) { - // Image already loaded, no need to call API - return; - } - - final profileVm = context.read(); - final patientID = _appState.getAuthenticatedUser()?.patientId; - - if (patientID != null) { - print('πŸ“₯ Loading profile image for patient: $patientID (forceRefresh: $forceRefresh)'); - profileVm.getProfileImage( - patientID: patientID, - forceRefresh: forceRefresh, - onSuccess: (data) { - print('βœ… Profile image loaded successfully'); - if (mounted) { - _tryCacheExistingImage(); - setState(() {}); // Rebuild with new cached bytes - } - }, - onError: (error) { - print('❌ Error loading profile image: $error'); - // Error loading image - }, - ); - } + // Check for user switch + context.read().checkForUserSwitch(); } void _pickImage() { - // Show image picker options without checking permissions first - ImageOptions.showImageOptionsNew( - context, - false, // Don't show files option, only camera and gallery - (base64String, file) async { - try { - print('=== Starting image processing ==='); - print('File path: ${file.path}'); - print('File exists: ${await file.exists()}'); - print('Original file size: ${await file.length() / 1024} KB'); - - // Compress and resize the image - print('Calling compressAndResizeImage...'); - final compressedFile = await ImageCompressionHelper.compressAndResizeImage(file); - - File finalFile; - String finalBase64; - - if (compressedFile == null) { - print('⚠️ Compression failed - using original file as fallback'); + final profilePictureViewModel = context.read(); - // Fallback: use original image if compression fails - final originalSize = await file.length(); - final maxSize = 1048576; // 1MB - - if (originalSize > maxSize) { - print('❌ Original file is too large: ${originalSize / 1024} KB'); - if (mounted) { - Utils.showToast( - LocaleKeys.imageSizeTooLarge.tr(context: context), - ); - } - return; - } - - print('βœ… Using original file (${originalSize / 1024} KB)'); - finalFile = file; - var bytes = await file.readAsBytes(); - finalBase64 = base64.encode(bytes); - } else { - // Check compressed file size - final fileSize = await compressedFile.length(); - final maxSize = 1048576; // 1MB - print('βœ… Compression successful: ${fileSize / 1024} KB'); - - if (fileSize > maxSize) { - print('❌ Compressed file still too large'); - if (mounted) { - Utils.showToast( - LocaleKeys.imageSizeTooLarge.tr(context: context), - ); - } - return; - } - - finalFile = compressedFile; - var bytes = await compressedFile.readAsBytes(); - finalBase64 = base64.encode(bytes); - } - - print('Converting to base64... Length: ${finalBase64.length}'); - - if (mounted) { - setState(() { - _selectedImage = finalFile; - }); - - print('πŸ“€ Starting upload...'); - // Upload the image - _uploadImage(finalBase64); - } - - print('=== Image processing complete ==='); - } catch (e, stackTrace) { - print('❌ Error in _pickImage: $e'); - print('Stack trace: $stackTrace'); - if (mounted) { - Utils.showToast( - LocaleKeys.failedToProcessImage.tr(context: context), - ); - } - } - }, - checkCameraPermission: _checkCameraPermission, - checkGalleryPermission: _checkGalleryPermission, - ); - } - - Future _checkCameraPermission() async { - try { - print('=== Checking camera permission ==='); - - // First check current status - PermissionStatus currentStatus = await Permission.camera.status; - print('Current camera permission status: $currentStatus'); - - // If already granted, return true - if (currentStatus.isGranted) { - print('βœ… Camera permission already granted'); - return true; - } - - // If denied or permanently denied, show settings dialog - if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) { - // Request permission first - PermissionStatus newStatus = await Permission.camera.request(); - print('Camera permission after request: $newStatus'); - - if (newStatus.isGranted) { - print('βœ… Camera permission granted'); - return true; - } - - // Still denied - show settings dialog - print('⚠️ Camera permission denied - showing settings dialog'); + profilePictureViewModel.pickImage( + context, + showImagePicker: ImageOptions.showImageOptionsNew, + compressImage: ImageCompressionHelper.compressAndResizeImage, + onSuccess: (data) { if (mounted) { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context), - isShowActionButtons: true, - onCancelTap: () { - Navigator.pop(context); - }, - onConfirmTap: () async { - openAppSettings(); - }, - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } - return false; - } - - // Request permission for the first time - PermissionStatus newStatus = await Permission.camera.request(); - print('Camera permission after request: $newStatus'); - - if (newStatus.isGranted) { - print('βœ… Camera permission granted'); - return true; - } - - // Denied - show settings dialog - print('❌ Camera permission denied - showing settings dialog'); - if (mounted) { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context), - isShowActionButtons: true, - onCancelTap: () { - Navigator.pop(context); - }, - onConfirmTap: () async { - openAppSettings(); - }, - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } - return false; - } catch (e) { - print('❌ Error checking camera permission: $e'); - if (mounted) { - Utils.showToast( - LocaleKeys.failedToCheckPermissions.tr(context: context), - ); - } - return false; - } - } - - Future _checkGalleryPermission() async { - try { - print('=== Checking gallery permission ==='); - - // For Android 13+ (API 33+), the Android Photo Picker handles permissions internally - // No need to request READ_MEDIA_IMAGES or READ_EXTERNAL_STORAGE permissions - if (Platform.isAndroid) { - print('βœ… Android Photo Picker will handle permissions internally'); - return true; - } - - // iOS: Check photos permission - Permission galleryPermission = Permission.photos; - - // First check current status - PermissionStatus currentStatus = await galleryPermission.status; - print('Current gallery permission status: $currentStatus'); - - // If already granted, return true - if (currentStatus.isGranted || currentStatus.isLimited) { - print('βœ… Gallery permission already granted'); - return true; - } - - // If denied or permanently denied, request permission first - if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) { - // Request permission first - PermissionStatus newStatus = await galleryPermission.request(); - print('Gallery permission after request: $newStatus'); - - if (newStatus.isGranted || newStatus.isLimited) { - print('βœ… Gallery permission granted'); - return true; + Utils.showToast(LocaleKeys.profileImageUpdatedSuccessfully.tr(context: context)); } - - // Still denied - show settings dialog - print('⚠️ Gallery permission denied - showing settings dialog'); + }, + onError: (error) { if (mounted) { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context), - isShowActionButtons: true, - onCancelTap: () { - Navigator.pop(context); - }, - onConfirmTap: () async { - openAppSettings(); - }, - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } - return false; - } - - // Request permission for the first time - PermissionStatus newStatus = await galleryPermission.request(); - print('Gallery permission after request: $newStatus'); - - if (newStatus.isGranted || newStatus.isLimited) { - print('βœ… Gallery permission granted'); - return true; - } - - // Denied - show settings dialog - print('❌ Gallery permission denied - showing settings dialog'); - if (mounted) { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: context), - context, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context), - isShowActionButtons: true, - onCancelTap: () { - Navigator.pop(context); - }, - onConfirmTap: () async { - openAppSettings(); - }, - ), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } - return false; - } catch (e) { - print('❌ Error checking gallery permission: $e'); - if (mounted) { - Utils.showToast( - LocaleKeys.failedToCheckPermissions.tr(context: context), - ); - } - return false; - } - } - - void _uploadImage(String base64String) { - final profileVm = context.read(); - final patientID = _appState.getAuthenticatedUser()?.patientId; - - if (patientID != null) { - profileVm.uploadProfileImage( - patientID: patientID, - imageData: base64String, - onSuccess: (data) { - if (mounted) { - // Update cached bytes immediately from the uploaded data - _tryCacheExistingImage(); - setState(() { - _selectedImage = null; // Clear selected image after successful upload - }); - print( - LocaleKeys.profileImageUpdatedSuccessfully.tr(context: context), - ); - } - }, - onError: (error) { Utils.showToast(error); - }, - ); - } + } + }, + imageSizeTooLargeMessage: LocaleKeys.imageSizeTooLarge.tr(context: context), + failedToProcessImageMessage: LocaleKeys.failedToProcessImage.tr(context: context), + checkCameraPermission: (ctx) => _permissionService.checkCameraPermission(ctx), + checkGalleryPermission: (ctx) => _permissionService.checkGalleryPermission(ctx), + ); } - Widget _buildProfileImage(ProfileSettingsViewModel profileVm) { + Widget _buildProfileImage(ProfilePictureViewModel profilePictureViewModel, ProfileSettingsViewModel profileVm) { // Always get fresh user data final currentUser = _appState.getAuthenticatedUser(); final currentPatientId = currentUser?.patientId; @@ -507,10 +99,10 @@ class _ProfilePictureWidgetState extends State { } // Show selected image if available (only during upload) - if (_selectedImage != null) { + if (profilePictureViewModel.selectedImage != null) { return ClipOval( child: Image.file( - _selectedImage!, + profilePictureViewModel.selectedImage!, width: 136.w, height: 136.h, fit: BoxFit.cover, @@ -518,30 +110,14 @@ class _ProfilePictureWidgetState extends State { ); } - // Use cached decoded bytes β€” update cache if source data changed - final String? imageData = GetIt.instance().getProfileImageData; - final String? currentHash = (imageData != null && imageData.isNotEmpty) ? '${imageData.length}_${imageData.hashCode}' : null; - - // Re-decode only if the underlying data actually changed - if (currentHash != null && currentHash != _cachedImageDataHash) { - try { - _cachedImageBytes = base64Decode(imageData!); - _cachedImageDataHash = currentHash; - } catch (e) { - print('❌ Error decoding profile image: $e'); - _cachedImageBytes = null; - _cachedImageDataHash = null; - } - } else if (currentHash == null) { - _cachedImageBytes = null; - _cachedImageDataHash = null; - } + // Update cache if needed + profilePictureViewModel.updateCacheIfNeeded(); // Show cached decoded image if available - if (_cachedImageBytes != null) { + if (profilePictureViewModel.cachedImageBytes != null) { return ClipOval( child: Image.memory( - _cachedImageBytes!, + profilePictureViewModel.cachedImageBytes!, key: ValueKey('profile_$currentPatientId'), width: 136.w, height: 136.h, @@ -564,33 +140,34 @@ class _ProfilePictureWidgetState extends State { @override Widget build(BuildContext context) { - // Removed addPostFrameCallback from build() β€” it was causing redundant - // _checkAndUpdateUserImage calls on every single rebuild. - // didChangeDependencies + didUpdateWidget already handle user switches. - - return Consumer( - builder: (context, profileVm, _) { + return Consumer2( + builder: (context, profilePictureViewModel, profileVm, _) { // If we already have cached bytes, show the image even while "loading" // to prevent the shimmerβ†’image blink on page open - final bool showShimmer = profileVm.isProfileImageLoading && _cachedImageBytes == null && _selectedImage == null; + final bool showShimmer = profilePictureViewModel.shouldShowShimmer(); return Center( child: Stack( children: [ - // Profile Image β€” use AnimatedSwitcher to smooth transition - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: showShimmer - ? Container( - key: const ValueKey('shimmer'), - width: 136.w, - height: 136.h, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.greyTextColor.withValues(alpha: 0.2), - ), - ).toShimmer2(isShow: true) - : _buildProfileImage(profileVm), + // Profile Image β€” use ValueListenableBuilder for targeted updates + ValueListenableBuilder( + valueListenable: profilePictureViewModel.profileImageVersion, + builder: (context, version, child) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: showShimmer + ? Container( + key: const ValueKey('shimmer'), + width: 136.w, + height: 136.h, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.greyTextColor.withValues(alpha: 0.2), + ), + ).toShimmer2(isShow: true) + : _buildProfileImage(profilePictureViewModel, profileVm), + ); + }, ), // Edit button diff --git a/lib/services/permission_service.dart b/lib/services/permission_service.dart index 62872340..5f749af8 100644 --- a/lib/services/permission_service.dart +++ b/lib/services/permission_service.dart @@ -1,9 +1,12 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; - -// import 'package:vibration/vibration.dart'; import 'package:geolocator/geolocator.dart' as geo; +import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; +import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; class PermissionService { // final LocalStorage storage = new LocalStorage("permission"); @@ -73,6 +76,206 @@ class PermissionService { openAppSettings(); } + /// Check and request camera permission with proper dialog handling + /// Returns true if permission is granted, false otherwise + Future checkCameraPermission(BuildContext context) async { + try { + print('=== Checking camera permission ==='); + + // First check current status + PermissionStatus currentStatus = await Permission.camera.status; + print('Current camera permission status: $currentStatus'); + + // If already granted, return true + if (currentStatus.isGranted) { + print('βœ… Camera permission already granted'); + return true; + } + + // If denied or permanently denied, show settings dialog + if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) { + // Request permission first + PermissionStatus newStatus = await Permission.camera.request(); + print('Camera permission after request: $newStatus'); + + if (newStatus.isGranted) { + print('βœ… Camera permission granted'); + return true; + } + + // Still denied - show settings dialog + print('⚠️ Camera permission denied - showing settings dialog'); + if (context.mounted) { + showCommonBottomSheetWithoutHeight( + title: LocaleKeys.notice.tr(context: context), + context, + child: Utils.getWarningWidget( + loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context), + isShowActionButtons: true, + onCancelTap: () { + Navigator.pop(context); + }, + onConfirmTap: () async { + openAppSettings(); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } + return false; + } + + // Request permission for the first time + PermissionStatus newStatus = await Permission.camera.request(); + print('Camera permission after request: $newStatus'); + + if (newStatus.isGranted) { + print('βœ… Camera permission granted'); + return true; + } + + // Denied - show settings dialog + print('❌ Camera permission denied - showing settings dialog'); + if (context.mounted) { + showCommonBottomSheetWithoutHeight( + title: LocaleKeys.notice.tr(context: context), + context, + child: Utils.getWarningWidget( + loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context), + isShowActionButtons: true, + onCancelTap: () { + Navigator.pop(context); + }, + onConfirmTap: () async { + openAppSettings(); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } + return false; + } catch (e) { + print('❌ Error checking camera permission: $e'); + if (context.mounted) { + Utils.showToast( + LocaleKeys.failedToCheckPermissions.tr(context: context), + ); + } + return false; + } + } + + /// Check and request gallery/photos permission with proper dialog handling + /// Returns true if permission is granted, false otherwise + Future checkGalleryPermission(BuildContext context) async { + try { + print('=== Checking gallery permission ==='); + + // For Android 13+ (API 33+), the Android Photo Picker handles permissions internally + // No need to request READ_MEDIA_IMAGES or READ_EXTERNAL_STORAGE permissions + // Determine which permission to check based on platform and Android version + Permission galleryPermission; + + if (Platform.isIOS) { + galleryPermission = Permission.photos; + } else { + // Android: use photos permission which handles API level differences automatically + print('βœ… Android Photo Picker will handle permissions internally'); + return true; + } + + // First check current status + PermissionStatus currentStatus = await galleryPermission.status; + print('Current gallery permission status: $currentStatus'); + + // If already granted, return true + if (currentStatus.isGranted || currentStatus.isLimited) { + print('βœ… Gallery permission already granted'); + return true; + } + + // If denied or permanently denied, request permission first + if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) { + // Request permission first + PermissionStatus newStatus = await galleryPermission.request(); + print('Gallery permission after request: $newStatus'); + + if (newStatus.isGranted || newStatus.isLimited) { + print('βœ… Gallery permission granted'); + return true; + } + + // Still denied - show settings dialog + print('⚠️ Gallery permission denied - showing settings dialog'); + if (context.mounted) { + showCommonBottomSheetWithoutHeight( + title: LocaleKeys.notice.tr(context: context), + context, + child: Utils.getWarningWidget( + loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context), + isShowActionButtons: true, + onCancelTap: () { + Navigator.pop(context); + }, + onConfirmTap: () async { + Navigator.pop(context); + openAppSettings(); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } + return false; + } + + // Request permission for the first time + PermissionStatus newStatus = await galleryPermission.request(); + print('Gallery permission after request: $newStatus'); + + if (newStatus.isGranted || newStatus.isLimited) { + print('βœ… Gallery permission granted'); + return true; + } + + // Denied - show settings dialog + print('❌ Gallery permission denied - showing settings dialog'); + if (context.mounted) { + showCommonBottomSheetWithoutHeight( + title: LocaleKeys.notice.tr(context: context), + context, + child: Utils.getWarningWidget( + loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context), + isShowActionButtons: true, + onCancelTap: () { + Navigator.pop(context); + }, + onConfirmTap: () async { + openAppSettings(); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } + return false; + } catch (e) { + print('❌ Error checking gallery permission: $e'); + if (context.mounted) { + Utils.showToast( + LocaleKeys.failedToCheckPermissions.tr(context: context), + ); + } + return false; + } + } + static isLocationEnabled() async { var permission = await geo.Geolocator.checkPermission(); if (permission == geo.LocationPermission.denied) { diff --git a/lib/widgets/image_picker.dart b/lib/widgets/image_picker.dart index 3fa7d061..1b6fd3ab 100644 --- a/lib/widgets/image_picker.dart +++ b/lib/widgets/image_picker.dart @@ -78,7 +78,7 @@ class ImageOptions { } }, onFilesTap: () async { - FilePickerResult? result = await FilePicker.platform.pickFiles( + FilePickerResult? result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: [ 'jpg', diff --git a/lib/widgets/user_avatar_widget.dart b/lib/widgets/user_avatar_widget.dart index 3271adf4..84499efa 100644 --- a/lib/widgets/user_avatar_widget.dart +++ b/lib/widgets/user_avatar_widget.dart @@ -64,11 +64,16 @@ class UserAvatarWidget extends StatelessWidget { if (profileImageData != null && profileImageData.isNotEmpty) { try { final bytes = base64Decode(profileImageData); + // Use a key based on data hash to help Flutter detect changes + final imageKey = ValueKey('avatar_${profileImageData.hashCode}'); + final imageWidget = Image.memory( bytes, + key: imageKey, width: width, height: height, fit: fit ?? BoxFit.cover, + gaplessPlayback: true, // Smooth transition without flicker ); return ClipRRect(