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 b4dd35f7..58d49f30 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 74fa0ee7..e46de55c 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.prod; + 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 2612bde9..3c80296d 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/dependencies.dart b/lib/core/dependencies.dart index 1de302b7..0380a9a3 100644 --- a/lib/core/dependencies.dart +++ b/lib/core/dependencies.dart @@ -46,10 +46,13 @@ 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/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'; @@ -77,6 +80,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'; @@ -149,6 +153,8 @@ class AppDependencies { () => LocalAuthService(loggerService: getIt(), localAuth: getIt()), ); + getIt.registerLazySingleton(() => PermissionService()); + // Repositories getIt.registerLazySingleton(() => CommonRepoImp(loggerService: getIt())); getIt.registerLazySingleton(() => AuthenticationRepoImp(loggerService: getIt(), apiClient: getIt())); @@ -188,6 +194,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 @@ -246,6 +253,11 @@ class AppDependencies { errorHandlerService: getIt(), )); + getIt.registerLazySingleton(() => ProfilePictureViewModel( + appState: getIt(), + profileSettingsViewModel: getIt(), + )); + getIt.registerLazySingleton(() => DateRangeSelectorRangeViewModel()); getIt.registerLazySingleton(() => DoctorFilterViewModel()); @@ -326,6 +338,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())); } } 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 38336a5a..e1c9ff8b 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(); @@ -943,7 +943,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; @@ -1198,7 +1199,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: @@ -1431,7 +1433,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: @@ -1502,7 +1505,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 7891357e..8c983eb7 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 ffe2043e..904c2674 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,8 +34,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'; @@ -168,6 +170,9 @@ void main() async { ChangeNotifierProvider( create: (_) => getIt.get(), ), + ChangeNotifierProvider( + create: (_) => getIt.get(), + ), ChangeNotifierProvider( create: (_) => getIt.get(), ), @@ -257,7 +262,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 5ea5b96b..5e12f575 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/todo_section/todo_section_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/presentation/appointments/appointment_queue_page.dart'; @@ -49,6 +50,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'; @@ -147,11 +150,14 @@ class _LandingPageState extends State { notificationsViewModel.initNotificationsViewModel(); insuranceViewModel.initInsuranceProvider(); - if(!appState.isRatedVisible) { - appointmentRatingViewModel.getLastRatingAppointment(onSuccess: (response) { - if (appointmentRatingViewModel.appointmentRatedList.isNotEmpty) { - appointmentRatingViewModel.getAppointmentDetails(appointmentRatingViewModel.appointmentRatedList.last.appointmentNo!, appointmentRatingViewModel.appointmentRatedList.last.projectID!, - onSuccess: ((response) { + if (!appState.isRatedVisible) { + appointmentRatingViewModel.getLastRatingAppointment( + onSuccess: (response) { + if (appointmentRatingViewModel.appointmentRatedList.isNotEmpty) { + appointmentRatingViewModel.getAppointmentDetails( + appointmentRatingViewModel.appointmentRatedList.last.appointmentNo!, + appointmentRatingViewModel.appointmentRatedList.last.projectID!, + onSuccess: ((response) { appointmentRatingViewModel.setClinicOrDoctor(false); appointmentRatingViewModel.setTitle(LocaleKeys.rateDoctor.tr(context: context)); appointmentRatingViewModel.setSubTitle(LocaleKeys.howWasYourLastVisitWithDoctor.tr(context: context)); @@ -210,10 +216,7 @@ class _LandingPageState extends State { controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.only( - top: (appState.isAuthenticated && - !insuranceVM.isInsuranceLoading && - insuranceVM.isInsuranceExpired && - insuranceVM.isInsuranceExpiryBannerShown) + top: (appState.isAuthenticated && !insuranceVM.isInsuranceLoading && insuranceVM.isInsuranceExpired && insuranceVM.isInsuranceExpiryBannerShown) ? (MediaQuery.paddingOf(context).top + 70.h) : kToolbarHeight + 0.h, bottom: 24), @@ -237,11 +240,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( @@ -333,12 +345,8 @@ class _LandingPageState extends State { // }), !appState.isAuthenticated ? Row(children: [ - SizedBox( - width: 24.w, - ), - Utils.buildSvgWithAssets( - icon: appState.isArabic() ? AppAssets.enLangIcon : AppAssets.arLangIcon, height: 24.h, width: 24.h) - .onPress(() { + 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')); }) ]) @@ -393,6 +401,36 @@ class _LandingPageState extends State { ), ).paddingSymmetrical(24.w, 0.h) : SizedBox.shrink(), + + // Offers And Discounts Carousel - Auto-scrolling from right to left + // appState.isAuthenticated + // ? + // 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: [ @@ -482,9 +520,7 @@ class _LandingPageState extends State { ), ) : SizedBox( - height: 255.h + - 20 + - 30, // itemHeight + shadow padding (10 top + 10 bottom) + pagination dots space + height: 255.h + 20 + 30, // itemHeight + shadow padding (10 top + 10 bottom) + pagination dots space child: Builder( builder: (context) { final int swiperItemCount = myAppointmentsVM.isMyAppointmentsLoading @@ -529,8 +565,7 @@ class _LandingPageState extends State { ? _buildLiveCareRequestCard().paddingSymmetrical(24.h, 16.h) : Container( width: double.infinity, - decoration: RoundedRectangleBorder() - .toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r, hasShadow: true), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r, hasShadow: true), child: Padding( padding: EdgeInsets.all(16.h), child: Column( @@ -630,9 +665,7 @@ class _LandingPageState extends State { LocaleKeys.quickLinks.tr(context: context).toText16(isBold: true), Row( children: [ - LocaleKeys.viewMedicalFileLandingPage - .tr(context: context) - .toText14(color: AppColors.primaryRedColor, isBold: true), + LocaleKeys.viewMedicalFileLandingPage.tr(context: context).toText14(color: AppColors.primaryRedColor, isBold: true), SizedBox(width: 2.h), Icon(Icons.arrow_forward_ios, color: AppColors.primaryRedColor, size: 14.h), ], @@ -660,10 +693,7 @@ class _LandingPageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - LocaleKeys.pendingAncillaryOrders - .tr(context: context) - .toText14(color: AppColors.eReferralCardColor, isBold: true) - .paddingSymmetrical(24.h, 0.h), + LocaleKeys.pendingAncillaryOrders.tr(context: context).toText14(color: AppColors.eReferralCardColor, isBold: true).paddingSymmetrical(24.h, 0.h), CustomButton( text: LocaleKeys.view.tr(context: context), onPressed: () { @@ -694,10 +724,7 @@ class _LandingPageState extends State { trackColor: Color(0xffD9D9D9), trackBorderColor: Colors.transparent, trackRadius: Radius.circular(10.0), - padding: EdgeInsets.only( - top: 92.h + 32.h, - left: MediaQuery.sizeOf(context).width / 2.5 - 10, - right: MediaQuery.sizeOf(context).width / 2.5 - 10), + padding: EdgeInsets.only(top: 92.h + 32.h, left: MediaQuery.sizeOf(context).width / 2.5 - 10, right: MediaQuery.sizeOf(context).width / 2.5 - 10), child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: LandingPageData().getLoggedInServiceCardsList.length, @@ -751,10 +778,7 @@ class _LandingPageState extends State { trackColor: Color(0xffD9D9D9), trackBorderColor: Colors.transparent, trackRadius: Radius.circular(10.0), - padding: EdgeInsets.only( - top: 92.h + 32.h, - left: MediaQuery.sizeOf(context).width / 2.5 - 10, - right: MediaQuery.sizeOf(context).width / 2.5 - 10), + padding: EdgeInsets.only(top: 92.h + 32.h, left: MediaQuery.sizeOf(context).width / 2.5 - 10, right: MediaQuery.sizeOf(context).width / 2.5 - 10), child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: LandingPageData.getNotLoggedInServiceCardsList.length, @@ -877,10 +901,7 @@ class _LandingPageState extends State { ), ), ), - (appState.isAuthenticated && - !insuranceVM.isInsuranceLoading && - insuranceVM.isInsuranceExpired && - insuranceVM.isInsuranceExpiryBannerShown) + (appState.isAuthenticated && !insuranceVM.isInsuranceLoading && insuranceVM.isInsuranceExpired && insuranceVM.isInsuranceExpiryBannerShown) ? Container( height: MediaQuery.paddingOf(context).top + 50.h, decoration: ShapeDecoration( @@ -897,24 +918,17 @@ class _LandingPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - LocaleKeys.insuranceExpiredOrInactive - .tr(context: context) - .toText14(color: AppColors.primaryRedColor, isBold: true) - .paddingSymmetrical(0.h, 0.h), + LocaleKeys.insuranceExpiredOrInactive.tr(context: context).toText14(color: AppColors.primaryRedColor, isBold: true).paddingSymmetrical(0.h, 0.h), Row( children: [ CustomButton( text: LocaleKeys.updateInsurance.tr(context: context), onPressed: () { insuranceVM.setIsInsuranceUpdateDetailsLoading(true); - insuranceVM.getPatientInsuranceDetailsForUpdate(appState.getAuthenticatedUser()!.patientId.toString(), - appState.getAuthenticatedUser()!.patientIdentificationNo.toString()); + insuranceVM.getPatientInsuranceDetailsForUpdate( + appState.getAuthenticatedUser()!.patientId.toString(), appState.getAuthenticatedUser()!.patientIdentificationNo.toString()); showCommonBottomSheetWithoutHeight(context, - child: PatientInsuranceCardUpdateCard(), - callBackFunc: () {}, - title: "", - isCloseButtonVisible: false, - isFullScreen: false); + child: PatientInsuranceCardUpdateCard(), callBackFunc: () {}, title: "", isCloseButtonVisible: false, isFullScreen: false); }, backgroundColor: AppColors.primaryRedColor, borderColor: AppColors.secondaryLightRedBorderColor, @@ -1064,8 +1078,7 @@ class _LandingPageState extends State { SizedBox(height: 6.h), _buildServingNowSection(), SizedBox(height: 5.h), - _buildQueueActionButton(currentStatus, currentQueue.roomNo ?? "") - .toShimmer2(isShow: myAppointmentsViewModel.patientQueueDetailsList.isEmpty), + _buildQueueActionButton(currentStatus, currentQueue.roomNo ?? "").toShimmer2(isShow: myAppointmentsViewModel.patientQueueDetailsList.isEmpty), ], ), ), @@ -1094,8 +1107,7 @@ class _LandingPageState extends State { hasShadow: false, ), padding: EdgeInsets.all(6.h), - child: Lottie.asset(AppAnimations.hourGlass, - repeat: true, reverse: false, frameRate: FrameRate(60), width: 40.h, height: 40.h, fit: BoxFit.fill)), + child: Lottie.asset(AppAnimations.hourGlass, repeat: true, reverse: false, frameRate: FrameRate(60), width: 40.h, height: 40.h, fit: BoxFit.fill)), ], ); } @@ -1274,8 +1286,7 @@ class _LandingPageState extends State { hasShadow: false, ), padding: EdgeInsets.all(6.h), - child: Lottie.asset(AppAnimations.hourGlass, - repeat: true, reverse: false, frameRate: FrameRate(60), width: 40.h, height: 40.h, fit: BoxFit.fill)), + child: Lottie.asset(AppAnimations.hourGlass, repeat: true, reverse: false, frameRate: FrameRate(60), width: 40.h, height: 40.h, fit: BoxFit.fill)), // Utils.buildSvgWithAssets( // icon: AppAssets.waiting_icon, // width: 24.h, @@ -1323,8 +1334,7 @@ class _LandingPageState extends State { // Appointment Card Wrapper (reusable) Widget _buildAppointmentCardWrapper(appointment) { return Container( - decoration: - RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r, hasShadow: true, hasDenseShadow: true), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r, hasShadow: true, hasDenseShadow: true), child: AppointmentCard( patientAppointmentHistoryResponseModel: appointment, myAppointmentsViewModel: myAppointmentsViewModel, diff --git a/lib/presentation/medical_file/medical_file_page.dart b/lib/presentation/medical_file/medical_file_page.dart index b60cd8bc..51bd94d7 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( 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 2ebb82b9..1f2ed8ff 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,318 +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 { - 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'); - - // 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 ==='); + final profilePictureViewModel = context.read(); - // 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 ==='); - - // 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 - 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 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) { @@ -553,40 +260,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..2692bbbc 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,202 @@ 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 + 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; + } + + // 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 { + 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(