diff --git a/assets/images/svg/hmg_logo_icon.svg b/assets/images/svg/hmg_logo_icon.svg new file mode 100644 index 00000000..36c5b5cc --- /dev/null +++ b/assets/images/svg/hmg_logo_icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/svg/qr_code.svg b/assets/images/svg/qr_code.svg new file mode 100644 index 00000000..57ba13cd --- /dev/null +++ b/assets/images/svg/qr_code.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index d45804d3..f5bf0d5c 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -1955,9 +1955,47 @@ "amountOnly": "المبلغ", "invoiceHistory": "سجل الفواتير", "refund": "استرداد", - "invoiceHistory": "سجل الفواتير", - "refund": "استرداد", "parkingQR": "رمز الاستجابة السريعة لوقوف السيارات", "parkingQRDesc": "امسح رمز الاستجابة السريعة (QR Code) أدناه عند بوابة الخروج من موقف السيارات:", - "paymentSuccessful": "تم الدفع بنجاح" + "paymentSuccessful": "تم الدفع بنجاح", + "typeAnyOffer": "اكتب أي عرض", + "validTill": "صالح حتى", + "buyNow": "اشتري الآن", + "availableAt": "متاح في", + "includedServices": "الخدمات المشمولة", + "off": "خصم", + "failedToLoadOfferDetails": "فشل في تحميل تفاصيل العرض", + "orderLabel": "الطلب", + "purchased": "تم الشراء", + "validUntil": "صالح حتى", + "viewOfferDetails": "عرض تفاصيل العرض", + "viewOrderDetails": "عرض تفاصيل الطلب", + "hospitalNA": "المستشفى: غير متوفر", + "hospitalLabel": "المستشفى", + "showLess": "عرض أقل", + "redeemed": "تم الاستخدام", + "partiallyRedeemed": "مستخدم جزئياً", + "available": "متاح", + "saveQRCode": "حفظ رمز الاستجابة السريعة", + "voucherNo": "رقم القسيمة", + "expiresOn": "ينتهي في", + "saveTheQRCode": "احفظ رمز الاستجابة السريعة", + "pleaseShowQRCodeInHospital": "يرجى حفظه على جهازك وإظهاره في المستشفى للمتابعة.", + "voucherCodeCopied": "تم نسخ رمز القسيمة إلى الحافظة", + "qrCodeNotAvailableToShare": "رمز الاستجابة السريعة غير متاح للمشاركة", + "failedToShareQRCode": "فشل في مشاركة رمز الاستجابة السريعة", + "failedToSaveQRCode": "فشل في حفظ رمز الاستجابة السريعة", + "qrCodeSavedSuccessfully": "تم حفظ رمز الاستجابة السريعة بنجاح", + "qrCodeSavedToDevice": "تم حفظ رمز الاستجابة السريعة على جهازك", + "viewOrderHistory": "عرض سجل الطلبات", + "qrCodeNotAvailable": "رمز الاستجابة السريعة غير متاح", + "errorLoadingQRCode": "خطأ في تحميل رمز الاستجابة السريعة", + "noOfferDetailsAvailable": "لا توجد تفاصيل عرض متاحة", + "noImagesAvailable": "لا توجد صور متاحة", + "checkOutThisAmazingOffer": "تحقق من هذا العرض المذهل!", + "offerPayment": "دفع العرض", + "unableToCreateOrder": "غير قادر على إنشاء الطلب. معلومات العرض أو المستخدم مفقودة.", + "failedToCreateOrder": "فشل في إنشاء الطلب. يرجى المحاولة مرة أخرى.", + "unableToCompletePayment": "غير قادر على إكمال الدفع. معلومات الطلب مفقودة.", + "paymentConfirmationFailed": "تم تسجيل الدفع لكن التأكيد فشل. يرجى الاتصال بالدعم." } diff --git a/lib/core/app_assets.dart b/lib/core/app_assets.dart index 2e27d633..7819ef40 100644 --- a/lib/core/app_assets.dart +++ b/lib/core/app_assets.dart @@ -398,6 +398,8 @@ class AppAssets { static const String habibLogoOnly = '$svgBasePath/habib logo_only.svg'; static const String descriptionIcon = '$svgBasePath/description_icon.svg'; static const String selectImageIcon = '$svgBasePath/select_image_icon.svg'; + static const String qrCodeIcon = '$svgBasePath/qr_code.svg'; + static const String hmgLogoSvg = '$svgBasePath/hmg_logo_icon.svg'; // PNGS // static const String hmgLogo = '$pngBasePath/hmg_logo.png'; 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 index f1e84717..b64479f1 100644 --- 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 @@ -1,12 +1,12 @@ import 'dart:convert'; class OffersAndDiscountsResponseModel { - List data; - Meta meta; + List? data; + Meta? meta; OffersAndDiscountsResponseModel({ - required this.data, - required this.meta, + this.data, + this.meta, }); factory OffersAndDiscountsResponseModel.fromRawJson(String str) => OffersAndDiscountsResponseModel.fromJson(json.decode(str)); @@ -14,41 +14,47 @@ class OffersAndDiscountsResponseModel { String toRawJson() => json.encode(toJson()); factory OffersAndDiscountsResponseModel.fromJson(Map json) => OffersAndDiscountsResponseModel( - data: List.from(json["data"].map((x) => OfferData.fromJson(x))), - meta: Meta.fromJson(json["meta"]), + data: json["data"] == null ? [] : List.from(json["data"]!.map((x) => OfferData.fromJson(x))), + meta: json["meta"] == null ? null : Meta.fromJson(json["meta"]), ); Map toJson() => { - "data": List.from(data.map((x) => x.toJson())), - "meta": meta.toJson(), + "data": data == null ? [] : List.from(data!.map((x) => x.toJson())), + "meta": meta?.toJson(), }; } class OfferData { - int id; - String ref; - String name; - String nameAr; - Category category; - List branches; - double listPrice; - Currency currency; - String imageUrl; - DateTime validFrom; - DateTime validTo; + int? id; + String? ref; + String? name; + String? nameAr; + BranchApplicability? branchApplicability; + Category? category; + List? branches; + double? listPrice; + double? vatExclusivePrice; + Currency? currency; + String? imageUrl; + DateTime? validFrom; + DateTime? validTo; + UsageValidity? usageValidity; OfferData({ - required this.id, - required this.ref, - required this.name, - required this.nameAr, - required this.category, - required this.branches, - required this.listPrice, - required this.currency, - required this.imageUrl, - required this.validFrom, - required this.validTo, + this.id, + this.ref, + this.name, + this.nameAr, + this.branchApplicability, + this.category, + this.branches, + this.listPrice, + this.vatExclusivePrice, + this.currency, + this.imageUrl, + this.validFrom, + this.validTo, + this.usageValidity, }); factory OfferData.fromRawJson(String str) => OfferData.fromJson(json.decode(str)); @@ -60,13 +66,16 @@ class OfferData { ref: json["ref"], name: json["name"], nameAr: json["name_ar"], - category: Category.fromJson(json["category"]), - branches: List.from(json["branches"].map((x) => Branch.fromJson(x))), + branchApplicability: branchApplicabilityValues.map[json["branch_applicability"]]!, + category: json["category"] == null ? null : Category.fromJson(json["category"]), + branches: json["branches"] == null ? [] : List.from(json["branches"]!.map((x) => Branch.fromJson(x))), listPrice: json["list_price"]?.toDouble(), + vatExclusivePrice: json["vat_exclusive_price"]?.toDouble(), currency: currencyValues.map[json["currency"]]!, imageUrl: json["image_url"], - validFrom: DateTime.parse(json["valid_from"]), - validTo: DateTime.parse(json["valid_to"]), + validFrom: json["valid_from"] == null ? null : DateTime.parse(json["valid_from"]), + validTo: json["valid_to"] == null ? null : DateTime.parse(json["valid_to"]), + usageValidity: json["usage_validity"] == null ? null : UsageValidity.fromJson(json["usage_validity"]), ); Map toJson() => { @@ -74,27 +83,40 @@ class OfferData { "ref": ref, "name": name, "name_ar": nameAr, - "category": category.toJson(), - "branches": List.from(branches.map((x) => x.toJson())), + "branch_applicability": branchApplicabilityValues.reverse[branchApplicability], + "category": category?.toJson(), + "branches": branches == null ? [] : List.from(branches!.map((x) => x.toJson())), "list_price": listPrice, + "vat_exclusive_price": vatExclusivePrice, "currency": currencyValues.reverse[currency], "image_url": imageUrl, - "valid_from": "${validFrom.year.toString().padLeft(4, '0')}-${validFrom.month.toString().padLeft(2, '0')}-${validFrom.day.toString().padLeft(2, '0')}", - "valid_to": "${validTo.year.toString().padLeft(4, '0')}-${validTo.month.toString().padLeft(2, '0')}-${validTo.day.toString().padLeft(2, '0')}", + "valid_from": "${validFrom!.year.toString().padLeft(4, '0')}-${validFrom!.month.toString().padLeft(2, '0')}-${validFrom!.day.toString().padLeft(2, '0')}", + "valid_to": "${validTo!.year.toString().padLeft(4, '0')}-${validTo!.month.toString().padLeft(2, '0')}-${validTo!.day.toString().padLeft(2, '0')}", + "usage_validity": usageValidity?.toJson(), }; } +enum BranchApplicability { + ALL, + SELECTED +} + +final branchApplicabilityValues = EnumValues({ + "all": BranchApplicability.ALL, + "selected": BranchApplicability.SELECTED +}); + class Branch { - int id; - String code; - String name; - String nameAr; + int? id; + String? code; + String? name; + String? nameAr; Branch({ - required this.id, - required this.code, - required this.name, - required this.nameAr, + this.id, + this.code, + this.name, + this.nameAr, }); factory Branch.fromRawJson(String str) => Branch.fromJson(json.decode(str)); @@ -117,16 +139,16 @@ class Branch { } class Category { - int id; - bool code; - String name; - String nameAr; + int? id; + bool? code; + String? name; + String? nameAr; Category({ - required this.id, - required this.code, - required this.name, - required this.nameAr, + this.id, + this.code, + this.name, + this.nameAr, }); factory Category.fromRawJson(String str) => Category.fromJson(json.decode(str)); @@ -156,17 +178,85 @@ final currencyValues = EnumValues({ "SAR": Currency.SAR }); +class UsageValidity { + Type? type; + dynamic days; + Rule? rule; + RuleAr? ruleAr; + dynamic redeemBy; + dynamic redeemByDisplay; + + UsageValidity({ + this.type, + this.days, + this.rule, + this.ruleAr, + this.redeemBy, + this.redeemByDisplay, + }); + + factory UsageValidity.fromRawJson(String str) => UsageValidity.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory UsageValidity.fromJson(Map json) => UsageValidity( + type: typeValues.map[json["type"]]!, + days: json["days"], + rule: ruleValues.map[json["rule"]]!, + ruleAr: ruleArValues.map[json["rule_ar"]]!, + redeemBy: json["redeem_by"], + redeemByDisplay: json["redeem_by_display"], + ); + + Map toJson() => { + "type": typeValues.reverse[type], + "days": days, + "rule": ruleValues.reverse[rule], + "rule_ar": ruleArValues.reverse[ruleAr], + "redeem_by": redeemBy, + "redeem_by_display": redeemByDisplay, + }; +} + +enum Rule { + REDEEM_BETWEEN_01072026_AND_02072026, + REDEEM_BETWEEN_08062026_AND_31082026 +} + +final ruleValues = EnumValues({ + "Redeem between 01/07/2026 and 02/07/2026.": Rule.REDEEM_BETWEEN_01072026_AND_02072026, + "Redeem between 08/06/2026 and 31/08/2026.": Rule.REDEEM_BETWEEN_08062026_AND_31082026 +}); + +enum RuleAr { + THE_0107202602072026, + THE_0806202631082026 +} + +final ruleArValues = EnumValues({ + "صرف في الفترة من 01/07/2026 إلى 02/07/2026.": RuleAr.THE_0107202602072026, + "صرف في الفترة من 08/06/2026 إلى 31/08/2026.": RuleAr.THE_0806202631082026 +}); + +enum Type { + OPEN_OFFER +} + +final typeValues = EnumValues({ + "open_offer": Type.OPEN_OFFER +}); + class Meta { - int total; - int page; - int perPage; - int pages; + int? total; + int? page; + int? perPage; + int? pages; Meta({ - required this.total, - required this.page, - required this.perPage, - required this.pages, + this.total, + this.page, + this.perPage, + this.pages, }); factory Meta.fromRawJson(String str) => Meta.fromJson(json.decode(str)); @@ -198,4 +288,4 @@ class EnumValues { reverseMap = map.map((k, v) => MapEntry(v, k)); return reverseMap; } -} \ No newline at end of file +} diff --git a/lib/features/offers_and_discounts/models/offers_purchases_history_response_model.dart b/lib/features/offers_and_discounts/models/offers_purchases_history_response_model.dart index f884ab66..f3ea2850 100644 --- a/lib/features/offers_and_discounts/models/offers_purchases_history_response_model.dart +++ b/lib/features/offers_and_discounts/models/offers_purchases_history_response_model.dart @@ -1,12 +1,12 @@ import 'dart:convert'; class OfferPurchaseHistoryResponseModel { - List data; - Meta meta; + List? data; + Meta? meta; OfferPurchaseHistoryResponseModel({ - required this.data, - required this.meta, + this.data, + this.meta, }); factory OfferPurchaseHistoryResponseModel.fromRawJson(String str) => OfferPurchaseHistoryResponseModel.fromJson(json.decode(str)); @@ -14,43 +14,43 @@ class OfferPurchaseHistoryResponseModel { String toRawJson() => json.encode(toJson()); factory OfferPurchaseHistoryResponseModel.fromJson(Map json) => OfferPurchaseHistoryResponseModel( - data: List.from(json["data"].map((x) => Datum.fromJson(x))), - meta: Meta.fromJson(json["meta"]), + data: json["data"] == null ? [] : List.from(json["data"]!.map((x) => Datum.fromJson(x))), + meta: json["meta"] == null ? null : Meta.fromJson(json["meta"]), ); Map toJson() => { - "data": List.from(data.map((x) => x.toJson())), - "meta": meta.toJson(), + "data": data == null ? [] : List.from(data!.map((x) => x.toJson())), + "meta": meta?.toJson(), }; } class Datum { - String voucherCode; - String state; - DateTime purchaseDate; + String? voucherCode; + String? state; + DateTime? purchaseDate; dynamic redemptionDate; - String qrCode; - String qrCodeUrl; - String pdfUrl; - DateTime validTo; - UsageValidity usageValidity; - Offer offer; - String branchApplicability; - List branches; + String? qrCode; + String? qrCodeUrl; + String? pdfUrl; + dynamic validTo; + UsageValidity? usageValidity; + Offer? offer; + String? branchApplicability; + List? branches; Datum({ - required this.voucherCode, - required this.state, - required this.purchaseDate, - required this.redemptionDate, - required this.qrCode, - required this.qrCodeUrl, - required this.pdfUrl, - required this.validTo, - required this.usageValidity, - required this.offer, - required this.branchApplicability, - required this.branches, + this.voucherCode, + this.state, + this.purchaseDate, + this.redemptionDate, + this.qrCode, + this.qrCodeUrl, + this.pdfUrl, + this.validTo, + this.usageValidity, + this.offer, + this.branchApplicability, + this.branches, }); factory Datum.fromRawJson(String str) => Datum.fromJson(json.decode(str)); @@ -60,43 +60,43 @@ class Datum { factory Datum.fromJson(Map json) => Datum( voucherCode: json["voucher_code"], state: json["state"], - purchaseDate: DateTime.parse(json["purchase_date"]), + purchaseDate: json["purchase_date"] == null ? null : DateTime.parse(json["purchase_date"]), redemptionDate: json["redemption_date"], qrCode: json["qr_code"], qrCodeUrl: json["qr_code_url"], pdfUrl: json["pdf_url"], - validTo: DateTime.parse(json["valid_to"]), - usageValidity: UsageValidity.fromJson(json["usage_validity"]), - offer: Offer.fromJson(json["offer"]), + validTo: json["valid_to"], + usageValidity: json["usage_validity"] == null ? null : UsageValidity.fromJson(json["usage_validity"]), + offer: json["offer"] == null ? null : Offer.fromJson(json["offer"]), branchApplicability: json["branch_applicability"], - branches: List.from(json["branches"].map((x) => Branch.fromJson(x))), + branches: json["branches"] == null ? [] : List.from(json["branches"]!.map((x) => Branch.fromJson(x))), ); Map toJson() => { "voucher_code": voucherCode, "state": state, - "purchase_date": purchaseDate.toIso8601String(), + "purchase_date": purchaseDate?.toIso8601String(), "redemption_date": redemptionDate, "qr_code": qrCode, "qr_code_url": qrCodeUrl, "pdf_url": pdfUrl, - "valid_to": "${validTo.year.toString().padLeft(4, '0')}-${validTo.month.toString().padLeft(2, '0')}-${validTo.day.toString().padLeft(2, '0')}", - "usage_validity": usageValidity.toJson(), - "offer": offer.toJson(), + "valid_to": validTo, + "usage_validity": usageValidity?.toJson(), + "offer": offer?.toJson(), "branch_applicability": branchApplicability, - "branches": List.from(branches.map((x) => x.toJson())), + "branches": branches == null ? [] : List.from(branches!.map((x) => x.toJson())), }; } class Branch { - String code; - String name; - String nameAr; + String? code; + String? name; + String? nameAr; Branch({ - required this.code, - required this.name, - required this.nameAr, + this.code, + this.name, + this.nameAr, }); factory Branch.fromRawJson(String str) => Branch.fromJson(json.decode(str)); @@ -117,20 +117,20 @@ class Branch { } class Offer { - int id; - String name; - String nameAr; - String branchApplicability; - DateTime validTo; - UsageValidity usageValidity; + int? id; + String? name; + String? nameAr; + String? branchApplicability; + DateTime? validTo; + UsageValidity? usageValidity; Offer({ - required this.id, - required this.name, - required this.nameAr, - required this.branchApplicability, - required this.validTo, - required this.usageValidity, + this.id, + this.name, + this.nameAr, + this.branchApplicability, + this.validTo, + this.usageValidity, }); factory Offer.fromRawJson(String str) => Offer.fromJson(json.decode(str)); @@ -142,8 +142,8 @@ class Offer { name: json["name"], nameAr: json["name_ar"], branchApplicability: json["branch_applicability"], - validTo: DateTime.parse(json["valid_to"]), - usageValidity: UsageValidity.fromJson(json["usage_validity"]), + validTo: json["valid_to"] == null ? null : DateTime.parse(json["valid_to"]), + usageValidity: json["usage_validity"] == null ? null : UsageValidity.fromJson(json["usage_validity"]), ); Map toJson() => { @@ -151,26 +151,26 @@ class Offer { "name": name, "name_ar": nameAr, "branch_applicability": branchApplicability, - "valid_to": "${validTo.year.toString().padLeft(4, '0')}-${validTo.month.toString().padLeft(2, '0')}-${validTo.day.toString().padLeft(2, '0')}", - "usage_validity": usageValidity.toJson(), + "valid_to": "${validTo!.year.toString().padLeft(4, '0')}-${validTo!.month.toString().padLeft(2, '0')}-${validTo!.day.toString().padLeft(2, '0')}", + "usage_validity": usageValidity?.toJson(), }; } class UsageValidity { - String type; + String? type; dynamic days; - String rule; - String ruleAr; - DateTime redeemBy; - String redeemByDisplay; + String? rule; + String? ruleAr; + dynamic redeemBy; + dynamic redeemByDisplay; UsageValidity({ - required this.type, - required this.days, - required this.rule, - required this.ruleAr, - required this.redeemBy, - required this.redeemByDisplay, + this.type, + this.days, + this.rule, + this.ruleAr, + this.redeemBy, + this.redeemByDisplay, }); factory UsageValidity.fromRawJson(String str) => UsageValidity.fromJson(json.decode(str)); @@ -182,7 +182,7 @@ class UsageValidity { days: json["days"], rule: json["rule"], ruleAr: json["rule_ar"], - redeemBy: DateTime.parse(json["redeem_by"]), + redeemBy: json["redeem_by"], redeemByDisplay: json["redeem_by_display"], ); @@ -191,22 +191,22 @@ class UsageValidity { "days": days, "rule": rule, "rule_ar": ruleAr, - "redeem_by": "${redeemBy.year.toString().padLeft(4, '0')}-${redeemBy.month.toString().padLeft(2, '0')}-${redeemBy.day.toString().padLeft(2, '0')}", + "redeem_by": redeemBy, "redeem_by_display": redeemByDisplay, }; } class Meta { - int total; - int page; - int perPage; - int pages; + int? total; + int? page; + int? perPage; + int? pages; Meta({ - required this.total, - required this.page, - required this.perPage, - required this.pages, + this.total, + this.page, + this.perPage, + this.pages, }); factory Meta.fromRawJson(String str) => Meta.fromJson(json.decode(str)); @@ -226,4 +226,4 @@ class Meta { "per_page": perPage, "pages": pages, }; -} \ No newline at end of file +} diff --git a/lib/features/offers_and_discounts/offers_and_discounts_repo.dart b/lib/features/offers_and_discounts/offers_and_discounts_repo.dart index fb86abe1..56e1410f 100644 --- a/lib/features/offers_and_discounts/offers_and_discounts_repo.dart +++ b/lib/features/offers_and_discounts/offers_and_discounts_repo.dart @@ -34,14 +34,15 @@ class OffersAndDiscountsRepoImp implements OffersAndDiscountsRepo { OffersAndDiscountsRepoImp({required this.loggerService, required this.apiClient}); @override - Future>> authenticatePatient(String patientId, String mobileNumber) async { + Future>> authenticatePatient(String nationalID, String mobileNumber) async { Map headers = { 'Content-Type': 'application/json', 'X-POMS-CHANNEL-API-KEY': ApiConsts.pomsApiKey, }; Map body = { - 'patient_id': patientId, + 'national_id': nationalID, + // 'patient_id': patientId, 'mobile_number': mobileNumber, }; 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 index 29cf4339..e7cab417 100644 --- a/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart +++ b/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart @@ -1,4 +1,9 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.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/features/offers_and_discounts/models/offers_and_discounts_response_model.dart'; @@ -11,6 +16,7 @@ import 'package:hmg_patient_app_new/features/offers_and_discounts/models/payment import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_purchases_history_response_model.dart' as history_model; 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'; +import 'package:path_provider/path_provider.dart'; class OffersAndDiscountsViewModel extends ChangeNotifier { bool isOffersLoading = false; @@ -18,6 +24,7 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { bool isOfferDetailsLoading = false; bool isAuthenticating = false; bool isPurchasing = false; + bool isSaveQrCodePressed = false; // Track QR code capture state List selectedCategories = []; // Empty means 'All Offers' String searchQuery = ''; @@ -32,14 +39,156 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { PomsAuthTokenModel? pomsAuthToken; OfferPurchaseResponseModel? lastPurchaseResponse; + // Image slider properties + Timer? _autoPlayTimer; + double _progress = 0.0; + bool _isPaused = false; + int _currentPage = 0; + List _promoImages = []; + static const Duration _pageDuration = Duration(seconds: 5); + static const Duration _timerInterval = Duration(milliseconds: 50); + + // Getters for slider state + double get progress => _progress; + + bool get isPaused => _isPaused; + + int get currentPage => _currentPage; + + List get promoImages => _promoImages; + OffersAndDiscountsViewModel({ required this.offersAndDiscountsRepo, required this.errorHandlerService, - }) {} + }); + + @override + void dispose() { + stopAutoPlayTimer(); + super.dispose(); + } // For loading state compatibility with existing code bool get isRegionListLoading => isOffersLoading; + // Initialize slider with page controller and initial index + void initializeSlider(int initialIndex) { + // Stop any existing timer + stopAutoPlayTimer(); + + // Clear previous images and state + _promoImages.clear(); + _progress = 0.0; + _isPaused = false; + _currentPage = initialIndex; + + notifyListeners(); + } + + // Collect all images from the offer details + List getAllImageUrls(OffersAndDiscountsDetailedResponseModel offer) { + List allImages = []; + + // Add main images + if (offer.images.main.isNotEmpty) { + allImages.addAll(offer.images.main.map((banner) => banner.url)); + } + + // Add banner images + if (offer.images.banners.isNotEmpty) { + allImages.addAll(offer.images.banners.map((banner) => banner.url)); + } + + // Add detail images + if (offer.images.details.isNotEmpty) { + allImages.addAll(offer.images.details.map((banner) => banner.url)); + } + + // If no images from the images object, use the main imageUrl + if (allImages.isEmpty && offer.imageUrl.isNotEmpty) { + allImages.add(offer.imageUrl); + } + + _promoImages = allImages; + print('📸 Loaded ${_promoImages.length} images for slider'); + notifyListeners(); + return allImages; + } + + // Start auto-play timer + void startAutoPlayTimer() { + _autoPlayTimer?.cancel(); + _progress = 0.0; + print('🎬 Starting auto-play timer with ${_promoImages.length} images'); + _autoPlayTimer = Timer.periodic(_timerInterval, (timer) { + if (!_isPaused && _promoImages.isNotEmpty) { + _progress += _timerInterval.inMilliseconds / _pageDuration.inMilliseconds; + + if (_progress >= 1.0) { + // Move to next page + _progress = 0.0; + final previousPage = _currentPage; + if (_currentPage < _promoImages.length - 1) { + _currentPage++; + } else { + // Last page reached, loop back to first page + _currentPage = 0; + } + print('🔄 Auto-switching from page $previousPage to $_currentPage'); + } + notifyListeners(); + } + }); + } + + // Stop auto-play timer + void stopAutoPlayTimer() { + _autoPlayTimer?.cancel(); + _autoPlayTimer = null; + } + + // Pause timer + void pauseTimer() { + _isPaused = true; + notifyListeners(); + } + + // Resume timer + void resumeTimer() { + _isPaused = false; + notifyListeners(); + } + + // Handle page change + void onPageChanged(int index) { + _currentPage = index; + _progress = 0.0; + notifyListeners(); + } + + // Navigate to next page + void nextPage() { + if (_currentPage < _promoImages.length - 1) { + _currentPage++; + } else { + // Loop back to first page + _currentPage = 0; + } + // Reset progress when manually changing page + _progress = 0.0; + notifyListeners(); + } + + // Reset slider state + void resetSliderState() { + stopAutoPlayTimer(); + _currentPage = 0; + _progress = 0.0; + _isPaused = false; + _promoImages.clear(); + notifyListeners(); + } + initOffersAndDiscounts({bool forceRefresh = false}) { // Only clear data if force refresh, otherwise keep existing data if (forceRefresh) { @@ -55,6 +204,14 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { getOffersAndDiscounts(); } + setIsoffersListLoading(bool val) { + if (val) { + isOffersLoading = true; + } + isOffersLoading = val; + notifyListeners(); + } + // Method to force refresh the offers list Future refreshOffersAndDiscounts({Function(dynamic)? onSuccess, Function(String)? onError}) async { offersList.clear(); @@ -102,10 +259,10 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { notifyListeners(); final result = await offersAndDiscountsRepo.authenticatePatient( - authenticatedUser.patientId.toString(), + authenticatedUser.patientIdentificationNo.toString(), // "4774103", // "2001273", - authenticatedUser.mobileNumber ?? "", + authenticatedUser.mobileNumber.toString(), ); result.fold( @@ -287,8 +444,8 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { final Map categoriesMap = {}; for (var offer in offersList) { - if (!categoriesMap.containsKey(offer.category.id)) { - categoriesMap[offer.category.id] = offer.category; + if (!categoriesMap.containsKey(offer.category!.id)) { + categoriesMap[offer.category!.id!] = offer.category!; } } @@ -328,7 +485,7 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { if (hasExistingData) { // Get new items that don't already exist in the list final existingIds = offersList.map((e) => e.id).toSet(); - final newItems = response.data!.data.where((item) => !existingIds.contains(item.id)).toList(); + final newItems = response.data!.data!.where((item) => !existingIds.contains(item.id)).toList(); // Append only new items if (newItems.isNotEmpty) { @@ -336,7 +493,7 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { } } else { // First load - replace the list - offersList = response.data!.data; + offersList = response.data!.data!; } meta = response.data!.meta; } @@ -349,6 +506,13 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { } Future getOfferDetails(int offerId, {Function(dynamic)? onSuccess, Function(String)? onError}) async { + // Clear previous offer details and images before loading new ones + offerDetails = null; + _promoImages.clear(); + stopAutoPlayTimer(); + _progress = 0.0; + _currentPage = 0; + isOfferDetailsLoading = true; notifyListeners(); @@ -389,6 +553,8 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { return; } + // Clear previous history data before loading new data + purchaseHistory = null; isHistoryLoading = true; notifyListeners(); @@ -416,6 +582,17 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { ); } + // Initialize history page - clears data and fetches fresh data + void initializeHistory() { + // Clear previous data + purchaseHistory = null; + searchQuery = ''; + notifyListeners(); + + // Fetch fresh data + getOffersAndDiscountsHistory(); + } + List get historyList { return purchaseHistory?.data ?? []; } @@ -426,15 +603,15 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { // Filter by categories (if any categories are selected) if (selectedCategories.isNotEmpty) { filtered = filtered.where((offer) { - return selectedCategories.contains(offer.category.id); + return selectedCategories.contains(offer.category!.id); }).toList(); } // Filter by search query if (searchQuery.isNotEmpty) { filtered = filtered.where((offer) { - final titleMatch = offer.name.toLowerCase().contains(searchQuery.toLowerCase()); - final titleArMatch = offer.nameAr.toLowerCase().contains(searchQuery.toLowerCase()); + final titleMatch = offer.name!.toLowerCase().contains(searchQuery.toLowerCase()); + final titleArMatch = offer.nameAr!.toLowerCase().contains(searchQuery.toLowerCase()); return titleMatch || titleArMatch; }).toList(); } @@ -448,13 +625,145 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { // Filter by search query if (searchQuery.isNotEmpty) { filtered = filtered.where((history) { - final offerNameMatch = history.offer.name.toLowerCase().contains(searchQuery.toLowerCase()); - final offerNameArMatch = history.offer.nameAr.toLowerCase().contains(searchQuery.toLowerCase()); - final voucherCodeMatch = history.voucherCode.toLowerCase().contains(searchQuery.toLowerCase()); + final offerNameMatch = history.offer!.name!.toLowerCase().contains(searchQuery.toLowerCase()); + final offerNameArMatch = history.offer!.nameAr!.toLowerCase().contains(searchQuery.toLowerCase()); + final voucherCodeMatch = history.voucherCode!.toLowerCase().contains(searchQuery.toLowerCase()); return offerNameMatch || offerNameArMatch || voucherCodeMatch; }).toList(); } return filtered; } + + /// Capture QR code screenshot and save to storage + Future> captureAndSaveQRCode({ + required GlobalKey screenshotKey, + required String voucherCode, + required bool isFromPaymentScreen, + required BuildContext context, + String? offerName, + String? offerNameAr, + }) async { + try { + final appState = getIt.get(); + + // Capture the screenshot + final RenderRepaintBoundary boundary = screenshotKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + + final ui.Image image = await boundary.toImage(pixelRatio: 3.0); + final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); + final Uint8List pngBytes = byteData!.buffer.asUint8List(); + + // Always save to external storage (permanent storage) + final String? savedFilePath = await saveToExternalStorage( + imageBytes: pngBytes, + voucherCode: voucherCode, + context: context, + ); + + // Prepare share data + final displayOfferName = appState.getLanguageID() == 1 ? offerNameAr : offerName; + + return { + 'success': savedFilePath != null, + 'filePath': savedFilePath, + 'isFromPayment': isFromPaymentScreen, + 'offerName': displayOfferName, + }; + } catch (e) { + print('Error capturing screenshot: $e'); + return { + 'success': false, + 'error': e.toString(), + }; + } + } + + /// Unified method to handle QR code capture and save/share with state management + Future> handleQRCodeAction({ + required GlobalKey screenshotKey, + required String voucherCode, + required bool shouldSaveToStorage, + required bool isFromPaymentScreen, + required BuildContext context, + String? offerName, + String? offerNameAr, + }) async { + try { + isSaveQrCodePressed = true; + notifyListeners(); + await Future.delayed(const Duration(milliseconds: 500)); + final result = await captureAndSaveQRCode( + screenshotKey: screenshotKey, + voucherCode: voucherCode, + isFromPaymentScreen: shouldSaveToStorage ? isFromPaymentScreen : false, + context: context, + offerName: offerName, + offerNameAr: offerNameAr); + + // Reset the flag + isSaveQrCodePressed = false; + notifyListeners(); + + return result; + } catch (e) { + print('Error in handleQRCodeAction: $e'); + // Reset the flag if error occurs + isSaveQrCodePressed = false; + notifyListeners(); + + return { + 'success': false, + 'error': e.toString(), + }; + } + } + + /// Save QR code to external storage (accessible by user) + Future saveToExternalStorage({ + required Uint8List imageBytes, + required String voucherCode, + required BuildContext context, + }) async { + try { + final String filename = 'QR_Code_${voucherCode}_${DateTime.now().millisecondsSinceEpoch}.png'; + + Directory? directory; + String? folderPath; + + if (Platform.isAndroid) { + // For Android: Use app-specific external storage (no permissions required) + // This directory is accessible via file managers in Android/data/[package]/files/Pictures + directory = await getExternalStorageDirectory(); + if (directory != null) { + // Save to app-specific external storage Pictures folder + folderPath = '${directory.path}/Pictures/HMG_QR_Codes'; + } + } else { + // For iOS, save to app documents directory (accessible via Files app) + directory = await getApplicationDocumentsDirectory(); + folderPath = '${directory.path}/QR_Codes'; + } + + if (folderPath == null) { + print('Failed to get storage path'); + return null; + } + + final Directory saveDirectory = Directory(folderPath); + if (!await saveDirectory.exists()) { + await saveDirectory.create(recursive: true); + } + + final String filepath = '$folderPath/$filename'; + final File file = File(filepath); + await file.writeAsBytes(imageBytes); + + print('QR Code saved to: $filepath'); + return filepath; + } catch (e) { + print('Error saving to external storage: $e'); + return null; + } + } } diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 86d8e65e..f6417046 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -1941,5 +1941,45 @@ abstract class LocaleKeys { static const parkingQR = 'parkingQR'; static const parkingQRDesc = 'parkingQRDesc'; static const paymentSuccessful = 'paymentSuccessful'; + static const typeAnyOffer = 'typeAnyOffer'; + static const validTill = 'validTill'; + static const buyNow = 'buyNow'; + static const availableAt = 'availableAt'; + static const includedServices = 'includedServices'; + static const off = 'off'; + static const failedToLoadOfferDetails = 'failedToLoadOfferDetails'; + static const orderLabel = 'orderLabel'; + static const purchased = 'purchased'; + static const validUntil = 'validUntil'; + static const viewOfferDetails = 'viewOfferDetails'; + static const viewOrderDetails = 'viewOrderDetails'; + static const hospitalNA = 'hospitalNA'; + static const hospitalLabel = 'hospitalLabel'; + static const showLess = 'showLess'; + static const redeemed = 'redeemed'; + static const partiallyRedeemed = 'partiallyRedeemed'; + static const available = 'available'; + static const saveQRCode = 'saveQRCode'; + static const voucherNo = 'voucherNo'; + static const expiresOn = 'expiresOn'; + static const saveTheQRCode = 'saveTheQRCode'; + static const pleaseShowQRCodeInHospital = 'pleaseShowQRCodeInHospital'; + static const voucherCodeCopied = 'voucherCodeCopied'; + static const qrCodeNotAvailableToShare = 'qrCodeNotAvailableToShare'; + static const failedToShareQRCode = 'failedToShareQRCode'; + static const failedToSaveQRCode = 'failedToSaveQRCode'; + static const qrCodeSavedSuccessfully = 'qrCodeSavedSuccessfully'; + static const qrCodeSavedToDevice = 'qrCodeSavedToDevice'; + static const viewOrderHistory = 'viewOrderHistory'; + static const qrCodeNotAvailable = 'qrCodeNotAvailable'; + static const errorLoadingQRCode = 'errorLoadingQRCode'; + static const noOfferDetailsAvailable = 'noOfferDetailsAvailable'; + static const noImagesAvailable = 'noImagesAvailable'; + static const checkOutThisAmazingOffer = 'checkOutThisAmazingOffer'; + static const offerPayment = 'offerPayment'; + static const unableToCreateOrder = 'unableToCreateOrder'; + static const failedToCreateOrder = 'failedToCreateOrder'; + static const unableToCompletePayment = 'unableToCompletePayment'; + static const paymentConfirmationFailed = 'paymentConfirmationFailed'; } diff --git a/lib/presentation/home/landing_page.dart b/lib/presentation/home/landing_page.dart index 440aaf6f..29b0c173 100644 --- a/lib/presentation/home/landing_page.dart +++ b/lib/presentation/home/landing_page.dart @@ -230,6 +230,8 @@ class _LandingPageState extends State { // Refresh Immediate LiveCare Data immediateLiveCareViewModel.initImmediateLiveCare(); immediateLiveCareViewModel.getPatientLiveCareHistory(); + offersAndDiscountsViewModel.setIsoffersListLoading(true); + offersAndDiscountsViewModel.initOffersAndDiscounts(); // appointmentRatingViewModel.getLastRatingAppointment( // onSuccess: (response) { @@ -452,29 +454,129 @@ class _LandingPageState extends State { // Offers And Discounts Carousel - Auto-scrolling from right to left //appState.isAuthenticated && - appState.isEnabledOffersAndDiscountsCarousel && offersAndDiscountsViewModel.offersList.isNotEmpty - ? 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), - ], + appState.isEnabledOffersAndDiscountsCarousel + ? Consumer( + builder: (context, offersVM, child) { + // Show loading state + if (offersVM.isOffersLoading && offersVM.offersList.isEmpty) { + return SizedBox( + height: 79.h, + child: ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: 0.w), + itemCount: 5, + itemBuilder: (context, index) { + 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), + ), + color: AppColors.whiteColor, + ), + 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: Container( + color: AppColors.greyColor, + child: Icon( + Icons.local_offer, + size: 24.h, + color: AppColors.greyTextColor, + ), + ), + ), + ), + ), + ), + ).toShimmer(); + }, + separatorBuilder: (context, index) => SizedBox(width: 12.w), ), - ], - ).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), - ], + ).paddingSymmetrical(24.h, 0.h); + + ///////// + + //////////// + // return 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), + // Container( + // height: 150.h, + // decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + // color: AppColors.whiteColor, + // borderRadius: 16.r, + // hasShadow: false, + // ), + // child: Center( + // child: CircularProgressIndicator( + // valueColor: AlwaysStoppedAnimation(AppColors.primaryRedColor), + // ), + // ), + // ).paddingSymmetrical(24.h, 0.h), + // SizedBox(height: 18.h), + // ], + // ); + } + + // Show offers if available + if (offersVM.offersList.isNotEmpty) { + return 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), + ], + ); + } + + // Hide if no offers and not loading + return SizedBox.shrink(); + }, ) : SizedBox.shrink(), 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 index bfccc7c0..43f842e1 100644 --- 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 @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; @@ -6,7 +7,6 @@ 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/features/offers_and_discounts/models/offers_and_discounts_detailed_response_model.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_discount_payment_page.dart'; @@ -17,6 +17,7 @@ import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:shimmer/shimmer.dart'; class OfferAndDiscountsFullScreenSwiperPage extends StatefulWidget { final int offerId; @@ -33,55 +34,34 @@ class OfferAndDiscountsFullScreenSwiperPage extends StatefulWidget { } class _OfferAndDiscountsFullScreenSwiperPageState extends State { - late PageController _pageController; - late int _currentPage; - late OffersAndDiscountsViewModel viewModel; late AppState appState; - List _promoImages = []; // Changed from late to regular variable @override void initState() { super.initState(); - _currentPage = widget.initialIndex; - _pageController = PageController(initialPage: widget.initialIndex); - // Fetch offer details + // Fetch offer details and initialize slider scheduleMicrotask(() { - viewModel = Provider.of(context, listen: false); - viewModel.getOfferDetails(widget.offerId); + final viewModel = Provider.of(context, listen: false); + viewModel.initializeSlider(widget.initialIndex); + viewModel.getOfferDetails(widget.offerId, onSuccess: (_) { + // Load images from the offer details + if (viewModel.offerDetails != null) { + viewModel.getAllImageUrls(viewModel.offerDetails!); + + // Start auto-play after images are loaded + if (viewModel.promoImages.isNotEmpty) { + viewModel.startAutoPlayTimer(); + } + } + }); }); } - // Collect all images from the offer details - List _getAllImageUrls(OffersAndDiscountsDetailedResponseModel offer) { - List allImages = []; - - // Add main images - if (offer.images.main.isNotEmpty) { - allImages.addAll(offer.images.main.map((banner) => banner.url)); - } - - // Add banner images - if (offer.images.banners.isNotEmpty) { - allImages.addAll(offer.images.banners.map((banner) => banner.url)); - } - - // Add detail images - if (offer.images.details.isNotEmpty) { - allImages.addAll(offer.images.details.map((banner) => banner.url)); - } - - // If no images from the images object, use the main imageUrl - if (allImages.isEmpty && offer.imageUrl.isNotEmpty) { - allImages.add(offer.imageUrl); - } - - return allImages; - } - @override void dispose() { - _pageController.dispose(); + final viewModel = Provider.of(context, listen: false); + viewModel.resetSliderState(); super.dispose(); } @@ -90,195 +70,167 @@ class _OfferAndDiscountsFullScreenSwiperPageState extends State(); return Scaffold( - backgroundColor: const Color(0xFFADA6D0), - body: Consumer( - builder: (context, vm, child) { - // Show loading state - if (vm.isOfferDetailsLoading) { - return SafeArea( - child: Stack( - children: [ - Center(child: Utils.getLoadingWidget()), - // Close button while loading - Positioned( - top: 26.h + 14.h, - right: 24.w, - child: _buildCloseButton(), - ), - ], - ), - ); - } - - final offer = vm.offerDetails; - if (offer == null) { - return SafeArea( - child: Stack( - children: [ - Center( - child: Text( - 'No offer details available', - style: TextStyle( - fontSize: 16.f, - color: AppColors.whiteColor, + body: SafeArea( + child: Consumer( + builder: (context, vm, child) { + // Show shimmer loading state + if (vm.isOfferDetailsLoading) { + return _buildShimmerLoading(); + } + + final offer = vm.offerDetails; + if (offer == null) { + return SafeArea( + child: Stack( + children: [ + Center( + child: Text( + LocaleKeys.noOfferDetailsAvailable.tr(context: context), + style: TextStyle( + fontSize: 16.f, + color: AppColors.whiteColor, + ), ), ), - ), - Positioned( - top: 26.h + 14.h, - right: 24.w, - child: _buildCloseButton(), - ), - ], - ), - ); - } - - // Get all images from the offer - _promoImages = _getAllImageUrls(offer); - - if (_promoImages.isEmpty) { - return SafeArea( - child: Stack( - children: [ - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.image_not_supported, - size: 64.h, - color: AppColors.greyTextColor, - ), - SizedBox(height: 16.h), - Text( - 'No images available', - style: TextStyle( - fontSize: 16.f, - color: AppColors.whiteColor, - ), - ), - ], + Positioned( + top: 26.h + 14.h, + right: appState.isArabic() ? null : 24.w, + left: appState.isArabic() ? 24.w : null, + child: _buildCloseButton(), ), - ), - Positioned( - top: 26.h + 14.h, - right: 24.w, - child: _buildCloseButton(), - ), - ], - ), - ); - } - - return 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: appState.isArabic(), - child: ClipRRect( - borderRadius: BorderRadius.circular(16.r), - child: Utils.buildImgWithNetwork( - url: _promoImages[index], - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - iconColor: AppColors.whiteColor, - errorBuilder: (context, error, stackTrace) { - return Container( - color: AppColors.greyColor, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.image_not_supported, - size: 64.h, - color: AppColors.greyTextColor, - ), - SizedBox(height: 16.h), - Text( - 'Failed to load image', - style: TextStyle( - fontSize: 14.f, - color: AppColors.greyTextColor, - ), - ), - ], - ), - ), - ); - }, - ), - ), + ], + ), + ); + } + + // Check if images are empty (show no images state) + if (vm.promoImages.isEmpty) { + return Container( + color: const Color(0xFFADA6D0), + child: SafeArea( + child: Stack( + children: [ + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported, + size: 64.h, + color: AppColors.greyTextColor, + ), + SizedBox(height: 16.h), + Text( + LocaleKeys.noImagesAvailable.tr(context: context), + style: TextStyle( + fontSize: 16.f, + color: AppColors.whiteColor, ), ), - ); - }, + ], + ), ), - ), - - SizedBox(height: 70.h), - // Bottom action buttons - _buildBottomActions(vm), + Positioned( + top: 26.h + 14.h, + right: 24.w, + child: _buildCloseButton(), + ), + ], + ), + ), + ); + } - SizedBox(height: 12.h), - ], + // Build page with image background + return Stack( + children: [ + // Blurred background image + Positioned.fill( + child: Utils.buildImgWithNetwork( + url: vm.promoImages[vm.currentPage], + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + iconColor: AppColors.whiteColor, + errorBuilder: (context, error, stackTrace) { + return Container(color: const Color(0xFFADA6D0)); + }, + ), ), + // Black shadow overlay + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.3), + ), + ), + // Main content + GestureDetector( + onTapDown: (_) => vm.pauseTimer(), + onTapUp: (_) => vm.resumeTimer(), + onTapCancel: () => vm.resumeTimer(), + child: Stack( + children: [ + Column( + children: [ + SizedBox(height: 26.h), + _buildPageIndicators(vm), + Expanded(child: SizedBox()), + _buildBottomActions(vm), + SizedBox(height: 12.h), + ], + ), - // Close button (top right) - Positioned( - top: 26.h + 14.h, // After indicator spacing - right: 24.w, - child: _buildCloseButton()), + // Close button (top right for LTR, top left for RTL) + Positioned( + top: 26.h + 14.h, // After indicator spacing + right: appState.isArabic() ? null : 24.w, + left: appState.isArabic() ? 24.w : null, + child: _buildCloseButton()), + ], + ), + ), ], - ), - ); - }, + ); + }, + ), ), ); } - Widget _buildPageIndicators() { + Widget _buildPageIndicators(OffersAndDiscountsViewModel vm) { + final isRTL = appState.isArabic(); + return Padding( padding: EdgeInsets.symmetric(horizontal: 24.w), child: Row( + textDirection: isRTL ? ui.TextDirection.rtl : ui.TextDirection.ltr, children: List.generate( - _promoImages.length, + vm.promoImages.length, (index) => Expanded( child: Container( margin: EdgeInsets.only( - right: index < _promoImages.length - 1 ? 4.w : 0, + right: isRTL ? 0 : (index < vm.promoImages.length - 1 ? 4.w : 0), + left: isRTL ? (index < vm.promoImages.length - 1 ? 4.w : 0) : 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 + color: const Color(0x33000000), // 20% opacity background + ), + child: FractionallySizedBox( + // For RTL, align progress from right to left + alignment: isRTL ? Alignment.centerRight : Alignment.centerLeft, + widthFactor: index < vm.currentPage + ? 1.0 // Completed pages - full + : index == vm.currentPage + ? vm.progress // Current page - animated progress + : 0.0, // Future pages - empty + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2.r), + color: Colors.white, + ), + ), ), ), ), @@ -311,14 +263,17 @@ class _OfferAndDiscountsFullScreenSwiperPageState extends State vm.nextPage(), + flipForRTL: isRTL, ), SizedBox(width: 12.w), @@ -384,7 +333,7 @@ class _OfferAndDiscountsFullScreenSwiperPageState extends State Expanded( + child: Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + margin: EdgeInsets.only( + right: appState.isArabic() ? 0 : (index < 2 ? 4.w : 0), + left: appState.isArabic() ? (index < 2 ? 4.w : 0) : 0, + ), + height: 4.h, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(2.r), color: Colors.white), + ), + ), + ), + ), + ), + ), + + SizedBox(height: 70.h), + + // Large image shimmer in center + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 0.h), + child: Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + ), + ), + ), + ), + ), + + SizedBox(height: 70.h), + + // Bottom action buttons shimmer + Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Row( + children: [ + // Buy Now button shimmer + Expanded( + child: Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + height: 56.h, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + ), + ), + ), + ), + + SizedBox(width: 12.w), + + // Next button shimmer + Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + width: 56.w, + height: 56.h, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + ), + ), + ), + + SizedBox(width: 12.w), + + // Share button shimmer + Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + width: 56.w, + height: 56.h, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + ), + ), + ), + ], + ), + ), + + SizedBox(height: 12.h), + ], + ), + + // Close button (top right) + Positioned( + top: 26.h + 14.h, + right: appState.isArabic() ? null : 24.w, + left: appState.isArabic() ? 24.w : null, + child: _buildCloseButton(), + ), + ], + ), + ); + } } diff --git a/lib/presentation/offers_and_discounts/offer_and_discounts_success_payment_qr_page.dart b/lib/presentation/offers_and_discounts/offer_and_discounts_success_payment_qr_page.dart index 2bdb2b5a..50e3a27a 100644 --- a/lib/presentation/offers_and_discounts/offer_and_discounts_success_payment_qr_page.dart +++ b/lib/presentation/offers_and_discounts/offer_and_discounts_success_payment_qr_page.dart @@ -1,6 +1,10 @@ +import 'dart:io'; +import 'dart:ui' as ui; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.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'; @@ -8,15 +12,23 @@ 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/models/offers_purchases_history_response_model.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_history_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/common_bottom_sheet.dart'; +import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; class OffersAndDiscountsSuccessQrPage extends StatefulWidget { final Datum purchaseData; + final bool isFromPaymentScreen; - const OffersAndDiscountsSuccessQrPage({super.key, required this.purchaseData}); + const OffersAndDiscountsSuccessQrPage({super.key, required this.purchaseData, required this.isFromPaymentScreen}); @override State createState() => _OffersAndDiscountsSuccessQrPageState(); @@ -24,267 +36,289 @@ class OffersAndDiscountsSuccessQrPage extends StatefulWidget { class _OffersAndDiscountsSuccessQrPageState extends State { late AppState appState; - - String _formatDate(DateTime date) { - try { - return DateFormat('dd MMM yyyy, hh:mm a').format(date); - } catch (e) { - return date.toString(); - } - } + late OffersAndDiscountsViewModel offersAndDiscountsViewModel; + String? qrCodeFilePath; // Store the QR code file path for sharing + final GlobalKey _screenshotKey = GlobalKey(); Color _getStatusColor(String status) { - switch (status.toLowerCase()) { - case 'active': - case 'valid': + switch (status) { + case 'purchased': return AppColors.successColor; + case 'redeemed': - case 'used': - return AppColors.warningColor; + return AppColors.greyTextColor; + + case 'partially_redeemed': + return AppColors.alertColor; + case 'expired': return AppColors.errorColor; + + case 'active': + case 'valid': + return AppColors.successColor; + default: - return AppColors.greyTextColor; + return AppColors.alertColor; } } String _getStatusText(String status) { - switch (status.toLowerCase()) { - case 'active': - case 'valid': - return LocaleKeys.active.tr(context: context); + switch (status) { + case 'purchased': + return LocaleKeys.purchased.tr(context: context); + case 'redeemed': - case 'used': - return "Redeemed"; + return LocaleKeys.redeemed.tr(context: context); + + case 'partially_redeemed': + return LocaleKeys.partiallyRedeemed.tr(context: context); + case 'expired': return LocaleKeys.expired.tr(context: context); + + case 'active': + case 'valid': + return LocaleKeys.active.tr(context: context); + default: - return status.toUpperCase(); + return ""; } } - void _copyVoucherCode() { - Clipboard.setData(ClipboardData(text: widget.purchaseData.voucherCode)); - Utils.showToast("Voucher code copied to clipboard"); + /// Handle QR code action by delegating to view model + Future _handleQRCodeAction({required bool isShareAction}) async { + try { + final result = await offersAndDiscountsViewModel.handleQRCodeAction( + screenshotKey: _screenshotKey, + voucherCode: widget.purchaseData.voucherCode!, + shouldSaveToStorage: true, // Always save to permanent storage + isFromPaymentScreen: isShareAction ? false : widget.isFromPaymentScreen, // Share action never pops + context: context, + offerName: widget.purchaseData.offer!.name, + offerNameAr: widget.purchaseData.offer!.nameAr, + ); + + if (!mounted) return; + + if (result['success']) { + if (isShareAction) { + // Share action: Saved to permanent storage, now share the file + final xFile = XFile(result['filePath']); + final offerName = result['offerName']; + // ignore: deprecated_member_use + await Share.shareXFiles( + [xFile], + text: '${LocaleKeys.saveQRCode.tr(context: context)} - ${LocaleKeys.orderLabel.tr(context: context)} #${widget.purchaseData.voucherCode} - $offerName', + subject: LocaleKeys.orderDetails.tr(context: context), + ); + } else { + // Save action: Show success and optionally navigate + if (result['isFromPayment']) { + // From payment screen: Show success bottom sheet and navigate to history + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getSuccessWidget(loadingText: LocaleKeys.qrCodeSavedSuccessfully.tr(context: context)), + isCloseButtonVisible: true, + isDismissible: true, + isFullScreen: false, + isAutoDismiss: true, + callBackFunc: () { + // Navigate after bottom sheet closes + if (mounted) { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + Navigator.of(context).push(CustomPageRoute(page: OffersAndDiscountsHistoryPage())); + } + }, + ); + } else { + // Not from payment screen: Show success bottom sheet and stay on page + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getSuccessWidget(loadingText: LocaleKeys.qrCodeSavedSuccessfully.tr(context: context)), + isCloseButtonVisible: true, + isDismissible: true, + isFullScreen: false, + isAutoDismiss: true, + ); + } + } + } else { + Utils.showToast(LocaleKeys.failedToSaveQRCode.tr(context: context)); + } + } catch (e) { + print('Error in _handleQRCodeAction: $e'); + Utils.showToast(LocaleKeys.failedToSaveQRCode.tr(context: context)); + } } @override Widget build(BuildContext context) { appState = getIt.get(); - - return Scaffold( - backgroundColor: AppColors.bgScaffoldColor, - body: CollapsingListView( - title: LocaleKeys.paymentSuccessful.tr(context: context), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 24.h), - - // Success Icon - Container( - width: 80.h, - height: 80.h, - decoration: BoxDecoration( - color: AppColors.successColor.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - Icons.check_circle, - size: 48.h, - color: AppColors.successColor, - ), - ), - - SizedBox(height: 16.h), - - LocaleKeys.paymentSuccessful.tr(context: context).toText24(isBold: true, fontWeight: FontWeight.w700), - - SizedBox(height: 8.h), - - "Your offer has been purchased successfully!".toText14(color: AppColors.greyTextColor), - - SizedBox(height: 32.h), - - // Order Reference Card (QR Code placeholder) - Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 20.h, - hasShadow: false, - ), - child: Column( - children: [ - LocaleKeys.orderNumber.tr(context: context).toText18(isBold: true, weight: FontWeight.w700), - SizedBox(height: 16.h), - Container( - padding: EdgeInsets.all(24.h), - decoration: BoxDecoration( - color: AppColors.whiteColor, - borderRadius: BorderRadius.circular(12.h), - border: Border.all(color: AppColors.greyColor.withValues(alpha: 0.3)), - ), + print(widget.isFromPaymentScreen); + return Consumer( + builder: (context, viewModel, child) { + offersAndDiscountsViewModel = viewModel; + + return Scaffold( + backgroundColor: AppColors.bgScaffoldColor, + body: CollapsingListView( + title: LocaleKeys.orderDetails.tr(context: context), + trailing: Utils.buildSvgWithAssets(icon: AppAssets.share, iconColor: AppColors.blackColor).onPress(() { + _handleQRCodeAction(isShareAction: true); // Share action - saves and shares + }), + bottomChild: CustomButton( + text: LocaleKeys.saveQRCode.tr(context: context), + onPressed: () { + _handleQRCodeAction(isShareAction: false); // Save action - saves and shows success + }, + backgroundColor: AppColors.successColor, + borderColor: AppColors.successColor, + textColor: AppColors.whiteColor, + icon: AppAssets.qrCodeIcon, + fontSize: 16.f, + isBold: true, + borderRadius: 12.r, + height: 56.h, + ).paddingSymmetrical(24.h, 24.h), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + //Save Qr Screenshot Area Start + RepaintBoundary( + key: _screenshotKey, + child: Container( + width: double.infinity, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.h, hasShadow: false), child: Column( children: [ - // QR Code Placeholder (can be replaced with actual QR code widget later) - Container( - width: 200.h, - height: 200.h, - decoration: BoxDecoration( - color: AppColors.bgScaffoldColor, - borderRadius: BorderRadius.circular(8.h), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.qr_code, - size: 80.h, - color: AppColors.greyTextColor, - ), - SizedBox(height: 8.h), - "QR Code".toText12(color: AppColors.greyTextColor), - ], - ), - ), + SizedBox(height: 24.h), + Utils.buildSvgWithAssets(icon: AppAssets.hmgLogoSvg, width: 48.w, height: 48.h), + SizedBox(height: 6.h), + _buildQRCode().paddingSymmetrical(0.h, 0.h), + AppCustomChipWidget( + labelText: "${LocaleKeys.expiresOn.tr(context: context)}: ${Utils.formatDateToDisplay(widget.purchaseData.offer!.validTo.toString())}", + backgroundColor: AppColors.lightRedButtonColor, + textColor: AppColors.primaryRedColor, + icon: "", + labelPadding: EdgeInsetsDirectional.only(start: 8.w, end: 8.w), + isEnglishOnly: true, ), - SizedBox(height: 16.h), - widget.purchaseData.voucherCode.toText20(isBold: true, weight: FontWeight.w700), - SizedBox(height: 8.h), - GestureDetector( - onTap: _copyVoucherCode, - child: Row( + SizedBox( + height: 6.h, + ), + if (viewModel.isSaveQrCodePressed) ...[ + Row( mainAxisAlignment: MainAxisAlignment.center, + textDirection: appState.isArabic() ? ui.TextDirection.rtl : ui.TextDirection.ltr, children: [ - Icon(Icons.copy, size: 16.h, color: AppColors.primaryRedColor), - SizedBox(width: 4.w), - "Copy".toText12(color: AppColors.primaryRedColor), + ("${LocaleKeys.voucherNo.tr(context: context)}: ").toText14(isBold: true, weight: FontWeight.w700), + "${widget.purchaseData.voucherCode}".toText14(isBold: true, weight: FontWeight.w700), ], ), - ), + ], + SizedBox(height: 24.h), ], ), ), - SizedBox(height: 12.h), - "Show this at the facility to redeem your offer".toText12(color: AppColors.greyTextColor), - ], - ).paddingSymmetrical(24.h, 24.h), - ).paddingSymmetrical(24.h, 0.h), - - SizedBox(height: 24.h), - - // Order Details Card - Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 20.h, - hasShadow: false, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + ).paddingSymmetrical(24.h, 24.h), + + // Save Qr Screenshot Area End + // Order Details Card + Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.h, hasShadow: false), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - LocaleKeys.orderDetails.tr(context: context).toText18(isBold: true, weight: FontWeight.w700), - Container( - padding: EdgeInsets.symmetric(horizontal: 12.h, vertical: 6.h), - decoration: BoxDecoration( - color: _getStatusColor(widget.purchaseData.state).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12.h), - ), - child: _getStatusText(widget.purchaseData.state).toText12( - isBold: true, - color: _getStatusColor(widget.purchaseData.state), - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + textDirection: appState.isArabic() ? ui.TextDirection.rtl : ui.TextDirection.ltr, + children: [ + Row( + textDirection: appState.isArabic() ? ui.TextDirection.rtl : ui.TextDirection.ltr, + children: [ + ("${LocaleKeys.voucherNo.tr(context: context)}: ").toText14(isBold: true, weight: FontWeight.w700), + "${widget.purchaseData.voucherCode}".toText14(isBold: true, weight: FontWeight.w700), + ], + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12.h, vertical: 6.h), + decoration: BoxDecoration( + color: _getStatusColor(widget.purchaseData.state!).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12.h), + ), + child: _getStatusText(widget.purchaseData.state!).toText10( + isBold: true, + color: _getStatusColor(widget.purchaseData.state!), + ), + ), + ], ), + SizedBox(height: 8.h), + SizedBox(height: 8.h), + LocaleKeys.saveTheQRCode.tr(context: context).toText21(isBold: true, weight: FontWeight.w700), + LocaleKeys.pleaseShowQRCodeInHospital.tr(context: context).toText12(isBold: true, fontWeight: FontWeight.w700), ], - ), - SizedBox(height: 20.h), - - // Purchase Date - _buildDetailRow( - LocaleKeys.date.tr(context: context), - _formatDate(widget.purchaseData.purchaseDate), - ), - - SizedBox(height: 12.h), - - // Valid Until - _buildDetailRow( - "Valid Until", - _formatDate(widget.purchaseData.validTo), - ), - - SizedBox(height: 12.h), - - // Offer Name - _buildDetailRow( - "Offer Name", - appState.getLanguageID() == 1 ? widget.purchaseData.offer.nameAr : widget.purchaseData.offer.name, - ), - - SizedBox(height: 12.h), - - Divider(color: AppColors.greyColor.withValues(alpha: 0.3)), - - SizedBox(height: 12.h), - - // Show offer description if available (note: Datum's Offer doesn't have description) - if (widget.purchaseData.usageValidity.rule.isNotEmpty) ...[ - LocaleKeys.description.tr(context: context).toText16(isBold: true, weight: FontWeight.w700), - SizedBox(height: 8.h), - (appState.getLanguageID() == 1 ? widget.purchaseData.usageValidity.ruleAr : widget.purchaseData.usageValidity.rule).toText14(color: AppColors.greyTextColor), - ], - ], - ).paddingSymmetrical(24.h, 24.h), - ).paddingSymmetrical(24.h, 0.h), - - SizedBox(height: 32.h), - - // Back to Home Button - CustomButton( - text: LocaleKeys.done.tr(context: context), - onPressed: () { - Navigator.of(context).popUntil((route) => route.isFirst); - }, - backgroundColor: AppColors.primaryRedColor, - borderColor: AppColors.primaryRedColor, - textColor: AppColors.whiteColor, - fontSize: 16.f, - isBold: true, - borderRadius: 12.r, - height: 56.h, - ).paddingSymmetrical(24.h, 0.h), - - SizedBox(height: 24.h), - ], + ).paddingSymmetrical(24.h, 24.h), + ).paddingSymmetrical(24.h, 0.h), + ], + ), + ), ), - ), - ), + ); + }, ); } - Widget _buildDetailRow(String label, String value) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 2, - child: label.toText14(color: AppColors.greyTextColor), + Widget _buildQRCode() { + if (widget.purchaseData.qrCode == null || widget.purchaseData.qrCode!.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.qr_code_2, size: 48.h, color: AppColors.greyTextColor), + SizedBox(height: 8.h), + LocaleKeys.qrCodeNotAvailable.tr(context: context).toText12(color: AppColors.greyTextColor), + ], ), - SizedBox(width: 16.w), - Expanded( - flex: 3, - child: Align( - alignment: Alignment.centerRight, - child: value.toText14(isBold: true), - ), - ), - ], + ); + } + + return FutureBuilder( + future: Utils.createFileFromString(widget.purchaseData.qrCode!, "png"), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Center( + child: CircularProgressIndicator(color: AppColors.primaryRedColor), + ); + } + + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48.h, color: AppColors.errorColor), + SizedBox(height: 8.h), + LocaleKeys.errorLoadingQRCode.tr(context: context).toText12(color: AppColors.errorColor), + ], + ), + ); + } + + if (snapshot.hasData && snapshot.data != null) { + qrCodeFilePath = snapshot.data; + return ClipRRect( + borderRadius: BorderRadius.circular(8.h), + child: Image.file(File(snapshot.data!), width: MediaQuery.of(context).size.width * 0.6.w, height: MediaQuery.of(context).size.width * 0.6.h, fit: BoxFit.cover), + ); + } + + return SizedBox(); + }, ); } } diff --git a/lib/presentation/offers_and_discounts/offers_and_discount_payment_page.dart b/lib/presentation/offers_and_discounts/offers_and_discount_payment_page.dart index 6bdcce64..c0b6a5f8 100644 --- a/lib/presentation/offers_and_discounts/offers_and_discount_payment_page.dart +++ b/lib/presentation/offers_and_discounts/offers_and_discount_payment_page.dart @@ -64,6 +64,23 @@ class _OffersAndDiscountPaymentPageState extends State(); @@ -116,7 +133,7 @@ class _OffersAndDiscountPaymentPageState extends State item.offer.id == selectedOffer.id, + (item) => item.offer!.id == selectedOffer.id, orElse: () => offersAndDiscountsViewModel.historyList.first, ); // Navigate to success QR page with purchase data Navigator.of(context).pushReplacement( CustomPageRoute( - page: OffersAndDiscountsSuccessQrPage(purchaseData: purchaseData), + page: OffersAndDiscountsSuccessQrPage(purchaseData: purchaseData, isFromPaymentScreen: true,), ), ); }, 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 index 923fbae9..ec302429 100644 --- a/lib/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart +++ b/lib/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -18,8 +19,10 @@ import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart'; import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; import 'package:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart'; import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; +import 'package:hmg_patient_app_new/widgets/loader/bottomsheet_loader.dart'; import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart'; import 'package:provider/provider.dart'; +import 'package:shimmer/shimmer.dart'; class OffersAndDiscountsDetailedPage extends StatefulWidget { final int offerId; @@ -33,15 +36,43 @@ class OffersAndDiscountsDetailedPage extends StatefulWidget { class _OffersAndDiscountsDetailedPageState extends State { late OffersAndDiscountsViewModel viewModel; late AppState appState; + bool hasLoadingError = false; @override void initState() { super.initState(); scheduleMicrotask(() { - viewModel.getOfferDetails(widget.offerId); + viewModel = Provider.of(context, listen: false); + // Clear previous offer details to prevent showing old data + viewModel.offerDetails = null; + + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.loadingText.tr(context: context)); + viewModel.getOfferDetails( + widget.offerId, + onSuccess: (response) { + LoaderBottomSheet.hideLoader(); + setState(() { + hasLoadingError = false; + }); + }, + onError: (error) { + LoaderBottomSheet.hideLoader(); + setState(() { + hasLoadingError = true; + }); + }, + ); }); } + @override + void dispose() { + // Optional: Clear offer details when leaving the page to save memory + // Uncomment if you want to clear cache on page exit + // viewModel.offerDetails = null; + super.dispose(); + } + String _formatDateString(DateTime date) { try { return DateFormat('d MMM, yyyy').format(date); @@ -54,9 +85,9 @@ class _OffersAndDiscountsDetailedPageState extends State( builder: (context, vm, child) { - if (vm.isOfferDetailsLoading) { - return Center(child: Utils.getLoadingWidget()); - } - final offer = vm.offerDetails; - if (offer == null) { - return Center(child: Text(LocaleKeys.noDataAvailable.tr(context: context))); + + // Show error if loading failed + if (hasLoadingError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64.h, color: AppColors.errorColor), + SizedBox(height: 16.h), + LocaleKeys.noDataAvailable.tr(context: context).toText16(isBold: true), + SizedBox(height: 8.h), + LocaleKeys.failedToLoadOfferDetails.tr(context: context).toText14(color: AppColors.greyTextColor), + ], + ), + ); } - print(vm.offerDetails!.toRawJson()); + // Only show offer if it matches the requested offerId (prevents showing old cached data) + if (offer == null || offer.id != widget.offerId) { + return SizedBox.shrink(); // Return empty widget while loading + } final status = _getOfferStatus(offer.validTo); return SingleChildScrollView( @@ -111,12 +193,15 @@ class _OffersAndDiscountsDetailedPageState extends State 0) - "${line.discountValue}% OFF".toText14( + "${line.discountValue}% ${LocaleKeys.off.tr(context: context)}".toText14( color: AppColors.primaryRedColor, isBold: true, ), @@ -288,52 +380,6 @@ class _OffersAndDiscountsDetailedPageState extends State { - late OffersAndDiscountsViewModel offersAndDiscountsViewModel; late AppState appState; + // Track which history items have expanded hospitals list + Set expandedHospitals = {}; + @override void initState() { + super.initState(); scheduleMicrotask(() { - offersAndDiscountsViewModel.getOffersAndDiscountsHistory(); + final offersAndDiscountsViewModel = Provider.of(context, listen: false); + offersAndDiscountsViewModel.initializeHistory(); }); - super.initState(); } @override Widget build(BuildContext context) { appState = getIt.get(); - offersAndDiscountsViewModel = Provider.of(context, listen: false); return CollapsingListView( title: LocaleKeys.order.tr(context: context), + isLeading: true, child: SingleChildScrollView( child: Consumer(builder: (context, offersAndDiscountVM, child) { + // Show loading state if (offersAndDiscountVM.isHistoryLoading) { - return Container( - padding: EdgeInsets.all(24.h), - child: Center( - child: CircularProgressIndicator( - color: AppColors.primaryRedColor, - ), - ), - ); + return _buildLoadingState(); } - final historyList = offersAndDiscountVM.filteredHistory; + List historyList = offersAndDiscountVM.filteredHistory; if (historyList.isEmpty) { - return Container( - padding: EdgeInsets.all(24.h), - child: Column( - children: [ - Icon( - Icons.history, - size: 64.h, - color: AppColors.greyTextColor, - ), - SizedBox(height: 16.h), - LocaleKeys.noDataAvailable.tr(context: context).toText18(isBold: true), - SizedBox(height: 8.h), - "You haven't purchased any offers yet.".toText14(color: AppColors.greyTextColor), - ], - ), - ); + return Utils.getNoDataWidget(context); } return Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(height: 24.h), ListView.separated( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), itemCount: historyList.length, + padding: EdgeInsets.only(bottom: 50), separatorBuilder: (context, index) => SizedBox(height: 16.h), itemBuilder: (context, index) { - final history = historyList[index]; - return Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.h, hasShadow: false), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppCustomChipWidget( - labelText: _getOfferStatus(history), - backgroundColor: _getOfferStatusBgColor(history), - textColor: _getOfferStatusTextColor(history), - labelPadding: EdgeInsetsDirectional.only(start: 12.w, end: 12.w, top: 2.h, bottom: 2.h), - ), - SizedBox(width: 8.w), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: (appState.getLanguageID() == 1 ? history.offer.nameAr : history.offer.name).toText16(isBold: true), - ), - ], - ), - SizedBox(height: 12.h), - Wrap( - spacing: 8.w, - runSpacing: 8.h, - children: [ - AppCustomChipWidget( - labelText: "Order: ${history.voucherCode}", - backgroundColor: AppColors.chipBgColor, - textColor: AppColors.greyTextColor, - icon: "", - labelPadding: EdgeInsetsDirectional.only(start: 8.w, end: 8.w), - ), - AppCustomChipWidget( - labelText: "Purchased: ${_formatDateChipFromDateTime(history.purchaseDate)}", - backgroundColor: AppColors.chipBgColor, - textColor: AppColors.greyTextColor, - icon: "", - labelPadding: EdgeInsetsDirectional.only(start: 8.w, end: 8.w), - ), - AppCustomChipWidget( - labelText: "Valid until: ${_formatDateChipFromDateTime(history.validTo)}", - backgroundColor: AppColors.chipBgColor, - textColor: AppColors.greyTextColor, - icon: "", - labelPadding: EdgeInsetsDirectional.only(start: 8.w, end: 8.w), - isEnglishOnly: true, - ), - AppCustomChipWidget( - labelText: - "Hospital: ${appState.getLanguageID() == 1 ? history.branches.first.nameAr : history.branches.first.name} ${history.branches.length > 1 ? '(+${history.branches.length - 1} more)' : ''}", - backgroundColor: AppColors.chipBgColor, - textColor: AppColors.greyTextColor, - icon: "", - labelPadding: EdgeInsetsDirectional.only(start: 8.w, end: 8.w), - ), - ], - ), - SizedBox(height: 12.h), - Row( - children: [ - Expanded( - child: CustomButton( - text: "View Offer Details", - onPressed: () {}, - backgroundColor: AppColors.lightRedButtonColor, - borderColor: AppColors.lightRedButtonColor, - textColor: AppColors.primaryRedColor, - borderWidth: 0.w, - ), - ), - SizedBox( - width: 9.w, - ), - Expanded( - child: CustomButton( - text: "View Order Details", - onPressed: () { - Navigator.of(context).push(CustomPageRoute(page: OffersAndDiscountsSuccessQrPage(purchaseData: history))); - }, - backgroundColor: AppColors.whiteColor, - borderColor: AppColors.textColor, - textColor: AppColors.textColor, - ), - ) - ], - ), - ], - ).paddingSymmetrical(16.h, 16.h), - ).paddingSymmetrical(24.h, 0.h); + Datum history = historyList[index]; + return AnimationConfiguration.staggeredList( + position: index, + duration: const Duration(milliseconds: 500), + child: SlideAnimation( + verticalOffset: 100.0, + child: FadeInAnimation( + child: _buildHistoryCard(history), + ), + ), + ); }, ), - SizedBox(height: 24.h), ], ); }), @@ -187,39 +97,135 @@ class _OffersAndDiscountsHistoryPageState extends State Container( + margin: EdgeInsets.only(bottom: 16.h), + child: Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + height: 200.h, + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(20.r), + ), + ), + ), + ), + ), + ), + ); } - String _getStatusText(String status) { - switch (status.toLowerCase()) { - case 'active': - case 'valid': - return LocaleKeys.active.tr(context: context); - case 'redeemed': - case 'used': - return "Redeemed"; - case 'expired': - return LocaleKeys.expired.tr(context: context); - case 'pending': - return LocaleKeys.pending.tr(context: context); - case 'cancelled': - return LocaleKeys.cancelled.tr(context: context); - default: - return status.toUpperCase(); - } + Widget _buildHistoryCard(Datum history) { + return AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: EdgeInsets.symmetric(vertical: 0.h), + child: Container( + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.h, hasShadow: false), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppCustomChipWidget( + labelText: _getOfferStatus(history), + backgroundColor: _getOfferStatusBgColor(history), + textColor: _getOfferStatusTextColor(history), + labelPadding: EdgeInsetsDirectional.only(start: 12.w, end: 12.w, top: 2.h, bottom: 2.h), + ), + SizedBox(width: 8.w), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + (appState.getLanguageID() == 1 ? history.offer!.nameAr ?? '' : history.offer!.name ?? '').toText16(isBold: true), + ], + ), + SizedBox(height: 12.h), + Wrap( + spacing: 8.w, + runSpacing: 8.h, + children: [ + AppCustomChipWidget( + labelText: "${LocaleKeys.orderLabel.tr(context: context)}: ${history.voucherCode}", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.my_sick_leave_icon, + labelPadding: EdgeInsetsDirectional.only(start: 0.w, end: 8.w), + ), + AppCustomChipWidget( + labelText: "${LocaleKeys.purchased.tr(context: context)}: ${_formatDateChipFromDateTime(history.purchaseDate!)}", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.calendar, + labelPadding: EdgeInsetsDirectional.only(start: 0.w, end: 8.w), + ), + history.offer!.validTo != null + ? AppCustomChipWidget( + labelText: "${LocaleKeys.validUntil.tr(context: context)}: ${_formatDateChipFromDateTime(history.offer!.validTo!)}", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.calendar, + labelPadding: EdgeInsetsDirectional.only(start: 0.w, end: 8.w), + isEnglishOnly: true, + ) + : SizedBox(), + // Hospital chips - show multiple chips when expanded + ...(_buildHospitalChips(history)), + ], + ), + SizedBox(height: 12.h), + Row( + children: [ + Expanded( + child: CustomButton( + text: LocaleKeys.viewOfferDetails.tr(), + onPressed: () { + Navigator.of(context).push( + CustomPageRoute( + page: OffersAndDiscountsDetailedPage(offerId: history.offer!.id!), + ), + ); + }, + backgroundColor: AppColors.lightRedButtonColor, + borderColor: AppColors.lightRedButtonColor, + textColor: AppColors.primaryRedColor, + borderWidth: 0.w, + fontWeight: FontWeight.w500, + fontSize: 14.f, + height: 40.h, + ), + ), + SizedBox(width: 9.w), + Expanded( + child: CustomButton( + text: LocaleKeys.viewOrderDetails.tr(), + onPressed: () { + Navigator.of(context).push(CustomPageRoute( + page: OffersAndDiscountsSuccessQrPage( + purchaseData: history, + isFromPaymentScreen: false, + ))); + }, + backgroundColor: AppColors.whiteColor, + borderColor: AppColors.textColor, + textColor: AppColors.textColor, + fontWeight: FontWeight.w500, + fontSize: 14.f, + height: 40.h, + borderWidth: 1.w, + ), + ), + ], + ), + ], + ).paddingSymmetrical(16.h, 16.h), + ).paddingSymmetrical(24.h, 0.h), + ); } String _formatDateChipFromDateTime(DateTime date) { @@ -230,50 +236,196 @@ class _OffersAndDiscountsHistoryPageState extends State _buildHospitalChips(dynamic history) { + final isExpanded = expandedHospitals.contains(history.voucherCode); + final isArabic = appState.getLanguageID() == 1; + + if (history.branches == null || history.branches!.isEmpty) { + return [ + AppCustomChipWidget( + labelText: "Hospital: N/A", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.hospital, + labelPadding: EdgeInsetsDirectional.only(start: 0.w, end: 8.w), + ), + ]; + } + + // If only one hospital, just show it + if (history.branches!.length == 1) { + final hospitalName = isArabic ? history.branches!.first.nameAr : history.branches!.first.name; + return [ + AppCustomChipWidget( + labelText: "Hospital: $hospitalName", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.hospital, + labelPadding: EdgeInsetsDirectional.only(start: 0.w, end: 8.w), + ), + ]; + } + + // Multiple hospitals + if (isExpanded) { + // Show each hospital as a separate chip + List chips = history.branches!.map((branch) { + final hospitalName = isArabic ? branch.nameAr : branch.name; + return AppCustomChipWidget( + labelText: "Hospital: $hospitalName", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.hospital, + labelPadding: EdgeInsetsDirectional.only(start: 0.w, end: 8.w), + ); + }).toList(); + + // Add a "Show less" chip at the end + chips.add( + AppCustomChipWidget( + labelText: "Show less", + backgroundColor: AppColors.primaryRedColor.withValues(alpha: 0.1), + textColor: AppColors.primaryRedColor, + icon: "", + labelPadding: EdgeInsetsDirectional.only(start: 8.w, end: 8.w), + onChipTap: () { + setState(() { + expandedHospitals.remove(history.voucherCode); + }); + }, + ), + ); + + return chips; + } else { + // Show collapsed view with count + final firstHospital = isArabic ? history.branches!.first.nameAr : history.branches!.first.name; + final remainingCount = history.branches!.length - 1; + return [ + AppCustomChipWidget( + labelText: "Hospital: $firstHospital (+$remainingCount more)", + backgroundColor: AppColors.chipBgColor, + textColor: AppColors.textColor, + icon: AppAssets.hospital, + labelPadding: EdgeInsetsDirectional.only(start: 0.w, end: 8.w), + onChipTap: () { + setState(() { + expandedHospitals.add(history.voucherCode!); + }); + }, + ), + ]; + } + } + String _getOfferStatus(dynamic history) { try { final now = DateTime.now(); + final state = history.state?.toLowerCase() ?? ''; - // Check if the offer has expired based on validTo date - if (now.isAfter(history.validTo)) { - return LocaleKeys.expired.tr(context: context); - } + // Check state first + switch (state) { + case 'purchased': + // Check if expired by date + if (history.validTo != null && now.isAfter(history.validTo)) { + return LocaleKeys.expired.tr(context: context); + } + return LocaleKeys.purchased.tr(context: context); - // Check state for active/available status - if (history.state.toLowerCase() == 'redeemed' || history.state.toLowerCase() == 'used') { - return LocaleKeys.expired.tr(context: context); - } + case 'redeemed': + return LocaleKeys.redeemed.tr(context: context); - if (history.state.toLowerCase() == 'active' || history.state.toLowerCase() == 'valid') { - return LocaleKeys.active.tr(context: context); - } + case 'partially_redeemed': + // Check if expired by date + if (history.validTo != null && now.isAfter(history.validTo)) { + return LocaleKeys.expired.tr(context: context); + } + return LocaleKeys.partiallyRedeemed.tr(context: context); + + case 'expired': + return LocaleKeys.expired.tr(context: context); - // For pending or other states, show as available - return "Available"; + case 'active': + case 'valid': + // Check if expired by date + if (history.validTo != null && now.isAfter(history.validTo)) { + return LocaleKeys.expired.tr(context: context); + } + return LocaleKeys.active.tr(context: context); + + default: + // Check if expired by date for unknown states + if (history.validTo != null && now.isAfter(history.validTo)) { + return LocaleKeys.expired.tr(context: context); + } + return LocaleKeys.available.tr(context: context); + } } catch (e) { return LocaleKeys.active.tr(context: context); } } Color _getOfferStatusBgColor(dynamic history) { - final status = _getOfferStatus(history); - if (status == LocaleKeys.active.tr(context: context)) { - return AppColors.successColor.withValues(alpha: 0.1); - } else if (status == LocaleKeys.expired.tr(context: context)) { + final state = history.state?.toLowerCase() ?? ''; + final now = DateTime.now(); + final isExpired = history.validTo != null && now.isAfter(history.validTo); + + // If expired by date, show error color + if (isExpired && state != 'redeemed') { return AppColors.errorColor.withValues(alpha: 0.1); - } else { - return AppColors.alertColor.withValues(alpha: 0.1); + } + + switch (state) { + case 'purchased': + return AppColors.successColor.withValues(alpha: 0.1); + + case 'redeemed': + return AppColors.greyTextColor.withValues(alpha: 0.1); + + case 'partially_redeemed': + return AppColors.alertColor.withValues(alpha: 0.1); + + case 'expired': + return AppColors.errorColor.withValues(alpha: 0.1); + + case 'active': + case 'valid': + return AppColors.successColor.withValues(alpha: 0.1); + + default: + return AppColors.alertColor.withValues(alpha: 0.1); } } Color _getOfferStatusTextColor(dynamic history) { - final status = _getOfferStatus(history); - if (status == LocaleKeys.active.tr(context: context)) { - return AppColors.successColor; - } else if (status == LocaleKeys.expired.tr(context: context)) { + final state = history.state?.toLowerCase() ?? ''; + final now = DateTime.now(); + final isExpired = history.validTo != null && now.isAfter(history.validTo); + + // If expired by date, show error color + if (isExpired && state != 'redeemed') { return AppColors.errorColor; - } else { - return AppColors.alertColor; + } + + switch (state) { + case 'purchased': + return AppColors.successColor; + + case 'redeemed': + return AppColors.greyTextColor; + + case 'partially_redeemed': + return AppColors.alertColor; + + case 'expired': + return AppColors.errorColor; + + case 'active': + case 'valid': + return AppColors.successColor; + + default: + return AppColors.alertColor; } } } diff --git a/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart b/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart index b1659eef..43d5c9d3 100644 --- a/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart +++ b/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -78,11 +79,7 @@ class _OffersAndDiscountsPageState extends State { baseColor: AppColors.shimmerBaseColor, highlightColor: AppColors.shimmerHighlightColor, child: Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 24.h, - hasShadow: true, - ), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.h, hasShadow: true), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -176,7 +173,8 @@ class _OffersAndDiscountsPageState extends State { // Search Input TextInputWidget( labelText: LocaleKeys.search.tr(context: context), - hintText: LocaleKeys.search.tr(context: context), + hintText: LocaleKeys.typeAnyOffer.tr(), + hintColor: AppColors.textColor, controller: _searchController, onChange: (value) { offersAndDiscountVM.setSearchQuery(value!); @@ -232,11 +230,11 @@ class _OffersAndDiscountsPageState extends State { gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2, crossAxisSpacing: 16.w, mainAxisSpacing: 16.h, childAspectRatio: 0.55), itemCount: offersAndDiscountVM.filteredOffers.length, itemBuilder: (context, index) { - return AnimationConfiguration.staggeredGrid( + return AnimationConfiguration.staggeredList( position: index, - duration: const Duration(milliseconds: 500), - columnCount: 2, - child: ScaleAnimation( + duration: const Duration(milliseconds: 375), + child: SlideAnimation( + verticalOffset: 50.0, child: FadeInAnimation( child: AnimatedContainer( duration: Duration(milliseconds: 300), @@ -249,7 +247,7 @@ class _OffersAndDiscountsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (offersAndDiscountVM.filteredOffers[index].imageUrl.isNotEmpty) + if (offersAndDiscountVM.filteredOffers[index].imageUrl != null) Expanded( flex: 3, child: Stack( @@ -258,7 +256,7 @@ class _OffersAndDiscountsPageState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(24.h)), child: SizedBox.expand( child: Utils.buildImgWithNetwork( - url: offersAndDiscountVM.filteredOffers[index].imageUrl, + url: offersAndDiscountVM.filteredOffers[index].imageUrl ?? "", iconColor: AppColors.blackBgColor, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { @@ -275,7 +273,7 @@ class _OffersAndDiscountsPageState extends State { Navigator.of(context).push( CustomPageRoute( page: OffersAndDiscountsDetailedPage( - offerId: offersAndDiscountVM.filteredOffers[index].id, + offerId: offersAndDiscountVM.filteredOffers[index].id!, ), ), ); @@ -291,20 +289,23 @@ class _OffersAndDiscountsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - appState.getLanguageID() == 1 ? offersAndDiscountVM.filteredOffers[index].nameAr : offersAndDiscountVM.filteredOffers[index].name, - style: TextStyle( - fontSize: 14.f, - fontWeight: FontWeight.w700, - color: AppColors.textColor, + SizedBox( + height: 40.h, + child: Text( + appState.getLanguageID() == 1 ? offersAndDiscountVM.filteredOffers[index].nameAr! : offersAndDiscountVM.filteredOffers[index].name!, + style: TextStyle( + fontSize: 14.f, + fontWeight: FontWeight.w700, + color: AppColors.textColor, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), SizedBox(height: 8.h), // Valid till chip AppCustomChipWidget( - labelText: "Valid till ${Utils.formatDateToDisplay(offersAndDiscountVM.filteredOffers[index].validTo.toString())}", + labelText: "${LocaleKeys.validTill.tr(context: context)} ${Utils.formatDateToDisplay(offersAndDiscountVM.filteredOffers[index].validTo.toString())}", backgroundColor: AppColors.chipBgColor, textColor: AppColors.textColor, icon: AppAssets.calendar, @@ -314,8 +315,8 @@ class _OffersAndDiscountsPageState extends State { labelPadding: EdgeInsetsDirectional.only(start: 4.w, end: 8.w), ), SizedBox(height: 20.h), - CustomButton( - text: 'Buy Now', + CustomButton( + text: LocaleKeys.buyNow.tr(context: context), icon: AppAssets.shoppingCart, iconColor: AppColors.whiteColor, onPressed: () async { @@ -346,7 +347,7 @@ class _OffersAndDiscountsPageState extends State { // Fetch offer details await offersAndDiscountVM.getOfferDetails( - offersAndDiscountVM.filteredOffers[index].id, + offersAndDiscountVM.filteredOffers[index].id!, onSuccess: (response) { LoaderBottomSheet.hideLoader(); if (offersAndDiscountVM.offerDetails != null) { @@ -395,11 +396,3 @@ class _OffersAndDiscountsPageState extends State { ); } } - -// Navigator.of(context).push( -// CustomPageRoute( -// page: OffersAndDiscountsDetailedPage( -// offerId: offersAndDiscountVM.filteredOffers[index].id, -// ), -// ), -// ); 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 index c62a17f5..a896240b 100644 --- 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 @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; @@ -29,7 +31,9 @@ class OffersAndDiscountTypeSelectionWidget extends StatelessWidget { return SingleChildScrollView( scrollDirection: Axis.horizontal, + reverse: isArabic, // Reverse scroll direction for RTL child: Row( + textDirection: isArabic ? ui.TextDirection.rtl : ui.TextDirection.ltr, children: [ // Show loading skeleton chips if (isLoading) ...[ @@ -57,10 +61,10 @@ class OffersAndDiscountTypeSelectionWidget extends StatelessWidget { // Dynamic category buttons ...categories .map((category) => Padding( - padding: EdgeInsets.only(right: 12.w), + padding: EdgeInsetsDirectional.only(end: 12.w), child: _buildOfferTypeCard( context: context, - title: isArabic ? category.nameAr : category.name, + title: isArabic ? category.nameAr ?? "" : category.name ?? "", categoryId: category.id, isSelected: selectedCategories.contains(category.id), ), diff --git a/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart b/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart index 211fed80..85b66a87 100644 --- a/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart +++ b/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart @@ -104,11 +104,11 @@ class _OffersAndDiscountsCarouselState extends State itemCount: viewModel.offersList.length, itemBuilder: (context, index) { final offer = viewModel.offersList[index]; - return _buildOfferItem(offer.imageUrl).onPress((){ + return _buildOfferItem(offer.imageUrl ?? "").onPress(() { Navigator.push( context, CustomPageRoute( - page: OfferAndDiscountsFullScreenSwiperPage(offerId: offer.id), + page: OfferAndDiscountsFullScreenSwiperPage(offerId: offer.id!), ), ); }); @@ -121,31 +121,28 @@ class _OffersAndDiscountsCarouselState extends State } Widget _buildSkeletonLoader() { - return SizedBox( - height: 79.h, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: EdgeInsets.symmetric(horizontal: 0.w), - itemCount: 5, // Show 5 skeleton items - itemBuilder: (context, index) { - return Shimmer.fromColors( - baseColor: AppColors.greyColor.withValues(alpha: 0.3), - highlightColor: AppColors.whiteColor.withValues(alpha: 0.5), - child: Container( - width: 79.w, - height: 79.h, - decoration: ShapeDecoration( - color: AppColors.greyColor, - shape: SmoothRectangleBorder( - borderRadius: BorderRadius.circular(13.r), - smoothness: 0.6, - ), + return ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: 0.w), + itemCount: 5, + itemBuilder: (context, index) { + return Shimmer.fromColors( + baseColor: AppColors.shimmerBaseColor, + highlightColor: AppColors.shimmerHighlightColor, + child: Container( + width: 79.w, + height: 79.h, + decoration: ShapeDecoration( + color: AppColors.shimmerBaseColor, + shape: SmoothRectangleBorder( + borderRadius: BorderRadius.circular(13.r), + smoothness: 0.6, ), ), - ); - }, - separatorBuilder: (context, index) => SizedBox(width: 12.w), - ), + ), + ); + }, + separatorBuilder: (context, index) => SizedBox(width: 12.w), ); }