diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index faf5fcb3..20d01a05 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -302,9 +302,12 @@ class ApiConsts { // POMS Offers and Discounts API static final pomsBaseUrl = 'https://poms-uat.odoo.com'; + static final pomsAuthPatient = '$pomsBaseUrl/api/v1/mobile/auth/patient'; static final pomsGetHospitalOffers = '$pomsBaseUrl/api/v1/mobile/hospital-offers'; static final pomsGetHospitalOffersDetails = '$pomsBaseUrl/api/v1/mobile/hospital-offers'; static final pomsGetOffersAndDiscountPurchasedHistory = '$pomsBaseUrl/api/v1/mobile/my/offers'; + static String pomsPurchaseOffer(int offerId) => '$pomsBaseUrl/api/v1/mobile/offers/$offerId/purchase'; + static String pomsPaymentResult(int orderId) => '$pomsBaseUrl/api/v1/mobile/orders/$orderId/payment-result'; // ************ static values for Api **************** static final double appVersionID = 21.3; diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart index 141b9939..ac67bc61 100644 --- a/lib/core/app_state.dart +++ b/lib/core/app_state.dart @@ -56,7 +56,7 @@ class AppState { void setIsPayfortHostedPageEnabled(bool value) { isPayfortHostedPageEnabled = value; - // isPayfortHostedPageEnabled = true; + // isPayfortHostedPageEnabled = false; } int? _superUserID; @@ -170,6 +170,31 @@ class AppState { set setIsAuthenticated(v) => isAuthenticated = v; + // POMS Authentication for Offers and Discounts + String _pomsAuthToken = ""; + DateTime? _pomsAuthTokenExpiresAt; + + String get getPomsAuthToken => _pomsAuthToken; + + DateTime? get getPomsAuthTokenExpiresAt => _pomsAuthTokenExpiresAt; + + void setPomsAuthToken(String token, DateTime expiresAt) { + _pomsAuthToken = token; + _pomsAuthTokenExpiresAt = expiresAt; + } + + void clearPomsAuthToken() { + _pomsAuthToken = ""; + _pomsAuthTokenExpiresAt = null; + } + + bool isPomsAuthTokenValid() { + if (_pomsAuthToken.isEmpty || _pomsAuthTokenExpiresAt == null) { + return false; + } + return DateTime.now().isBefore(_pomsAuthTokenExpiresAt!); + } + String deviceTypeID = ""; diff --git a/lib/core/enums.dart b/lib/core/enums.dart index 6fa2e11b..60bf8b7a 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -336,7 +336,7 @@ extension ServiceTypeEnumExt on ServiceTypeEnum { case ServiceTypeEnum.liveCareAppointment: return 4; case ServiceTypeEnum.offerPayment: - return 5; + return 18; } } } diff --git a/lib/features/offers_and_discounts/models/offer_purchase_request_model.dart b/lib/features/offers_and_discounts/models/offer_purchase_request_model.dart new file mode 100644 index 00000000..678cb09d --- /dev/null +++ b/lib/features/offers_and_discounts/models/offer_purchase_request_model.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +class OfferPurchaseRequestModel { + String identificationType; + String idValue; + String mobileNumber; + + OfferPurchaseRequestModel({ + required this.identificationType, + required this.idValue, + required this.mobileNumber, + }); + + factory OfferPurchaseRequestModel.fromRawJson(String str) => OfferPurchaseRequestModel.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory OfferPurchaseRequestModel.fromJson(Map json) => OfferPurchaseRequestModel( + identificationType: json["identification_type"], + idValue: json["id_value"], + mobileNumber: json["mobile_number"], + ); + + Map toJson() => { + "identification_type": identificationType, + "id_value": idValue, + "mobile_number": mobileNumber, + }; +} + diff --git a/lib/features/offers_and_discounts/models/offer_purchase_response_model.dart b/lib/features/offers_and_discounts/models/offer_purchase_response_model.dart new file mode 100644 index 00000000..693e682e --- /dev/null +++ b/lib/features/offers_and_discounts/models/offer_purchase_response_model.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +class OfferPurchaseResponseModel { + int orderId; + String orderRef; + String amount; + String currency; + bool paymentRequired; + dynamic paymentUrl; + + OfferPurchaseResponseModel({ + required this.orderId, + required this.orderRef, + required this.amount, + required this.currency, + required this.paymentRequired, + required this.paymentUrl, + }); + + factory OfferPurchaseResponseModel.fromRawJson(String str) => OfferPurchaseResponseModel.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory OfferPurchaseResponseModel.fromJson(Map json) => OfferPurchaseResponseModel( + orderId: json["order_id"], + orderRef: json["order_ref"], + amount: json["amount"], + currency: json["currency"], + paymentRequired: json["payment_required"], + paymentUrl: json["payment_url"], + ); + + Map toJson() => { + "order_id": orderId, + "order_ref": orderRef, + "amount": amount, + "currency": currency, + "payment_required": paymentRequired, + "payment_url": paymentUrl, + }; +} diff --git a/lib/features/offers_and_discounts/models/offers_and_discounts_history_purchased_model.dart b/lib/features/offers_and_discounts/models/offers_and_discounts_history_purchased_model.dart index e69de29b..f884ab66 100644 --- a/lib/features/offers_and_discounts/models/offers_and_discounts_history_purchased_model.dart +++ b/lib/features/offers_and_discounts/models/offers_and_discounts_history_purchased_model.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; + +class OfferPurchaseHistoryResponseModel { + List data; + Meta meta; + + OfferPurchaseHistoryResponseModel({ + required this.data, + required this.meta, + }); + + factory OfferPurchaseHistoryResponseModel.fromRawJson(String str) => OfferPurchaseHistoryResponseModel.fromJson(json.decode(str)); + + 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"]), + ); + + Map toJson() => { + "data": List.from(data.map((x) => x.toJson())), + "meta": meta.toJson(), + }; +} + +class Datum { + String voucherCode; + String state; + DateTime purchaseDate; + dynamic redemptionDate; + String qrCode; + String qrCodeUrl; + String pdfUrl; + DateTime 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, + }); + + factory Datum.fromRawJson(String str) => Datum.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Datum.fromJson(Map json) => Datum( + voucherCode: json["voucher_code"], + state: json["state"], + purchaseDate: 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"]), + branchApplicability: json["branch_applicability"], + branches: List.from(json["branches"].map((x) => Branch.fromJson(x))), + ); + + Map toJson() => { + "voucher_code": voucherCode, + "state": state, + "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(), + "branch_applicability": branchApplicability, + "branches": List.from(branches.map((x) => x.toJson())), + }; +} + +class Branch { + String code; + String name; + String nameAr; + + Branch({ + required this.code, + required this.name, + required this.nameAr, + }); + + factory Branch.fromRawJson(String str) => Branch.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Branch.fromJson(Map json) => Branch( + code: json["code"], + name: json["name"], + nameAr: json["name_ar"], + ); + + Map toJson() => { + "code": code, + "name": name, + "name_ar": nameAr, + }; +} + +class Offer { + 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, + }); + + factory Offer.fromRawJson(String str) => Offer.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Offer.fromJson(Map json) => Offer( + id: json["id"], + name: json["name"], + nameAr: json["name_ar"], + branchApplicability: json["branch_applicability"], + validTo: DateTime.parse(json["valid_to"]), + usageValidity: UsageValidity.fromJson(json["usage_validity"]), + ); + + Map toJson() => { + "id": id, + "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(), + }; +} + +class UsageValidity { + String type; + dynamic days; + String rule; + String ruleAr; + DateTime redeemBy; + String redeemByDisplay; + + UsageValidity({ + required this.type, + required this.days, + required this.rule, + required this.ruleAr, + required this.redeemBy, + required this.redeemByDisplay, + }); + + factory UsageValidity.fromRawJson(String str) => UsageValidity.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory UsageValidity.fromJson(Map json) => UsageValidity( + type: json["type"], + days: json["days"], + rule: json["rule"], + ruleAr: json["rule_ar"], + redeemBy: DateTime.parse(json["redeem_by"]), + redeemByDisplay: json["redeem_by_display"], + ); + + Map toJson() => { + "type": type, + "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_display": redeemByDisplay, + }; +} + +class Meta { + int total; + int page; + int perPage; + int pages; + + Meta({ + required this.total, + required this.page, + required this.perPage, + required this.pages, + }); + + factory Meta.fromRawJson(String str) => Meta.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Meta.fromJson(Map json) => Meta( + total: json["total"], + page: json["page"], + perPage: json["per_page"], + pages: json["pages"], + ); + + Map toJson() => { + "total": total, + "page": page, + "per_page": perPage, + "pages": pages, + }; +} \ 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 new file mode 100644 index 00000000..f884ab66 --- /dev/null +++ b/lib/features/offers_and_discounts/models/offers_purchases_history_response_model.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; + +class OfferPurchaseHistoryResponseModel { + List data; + Meta meta; + + OfferPurchaseHistoryResponseModel({ + required this.data, + required this.meta, + }); + + factory OfferPurchaseHistoryResponseModel.fromRawJson(String str) => OfferPurchaseHistoryResponseModel.fromJson(json.decode(str)); + + 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"]), + ); + + Map toJson() => { + "data": List.from(data.map((x) => x.toJson())), + "meta": meta.toJson(), + }; +} + +class Datum { + String voucherCode; + String state; + DateTime purchaseDate; + dynamic redemptionDate; + String qrCode; + String qrCodeUrl; + String pdfUrl; + DateTime 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, + }); + + factory Datum.fromRawJson(String str) => Datum.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Datum.fromJson(Map json) => Datum( + voucherCode: json["voucher_code"], + state: json["state"], + purchaseDate: 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"]), + branchApplicability: json["branch_applicability"], + branches: List.from(json["branches"].map((x) => Branch.fromJson(x))), + ); + + Map toJson() => { + "voucher_code": voucherCode, + "state": state, + "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(), + "branch_applicability": branchApplicability, + "branches": List.from(branches.map((x) => x.toJson())), + }; +} + +class Branch { + String code; + String name; + String nameAr; + + Branch({ + required this.code, + required this.name, + required this.nameAr, + }); + + factory Branch.fromRawJson(String str) => Branch.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Branch.fromJson(Map json) => Branch( + code: json["code"], + name: json["name"], + nameAr: json["name_ar"], + ); + + Map toJson() => { + "code": code, + "name": name, + "name_ar": nameAr, + }; +} + +class Offer { + 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, + }); + + factory Offer.fromRawJson(String str) => Offer.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Offer.fromJson(Map json) => Offer( + id: json["id"], + name: json["name"], + nameAr: json["name_ar"], + branchApplicability: json["branch_applicability"], + validTo: DateTime.parse(json["valid_to"]), + usageValidity: UsageValidity.fromJson(json["usage_validity"]), + ); + + Map toJson() => { + "id": id, + "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(), + }; +} + +class UsageValidity { + String type; + dynamic days; + String rule; + String ruleAr; + DateTime redeemBy; + String redeemByDisplay; + + UsageValidity({ + required this.type, + required this.days, + required this.rule, + required this.ruleAr, + required this.redeemBy, + required this.redeemByDisplay, + }); + + factory UsageValidity.fromRawJson(String str) => UsageValidity.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory UsageValidity.fromJson(Map json) => UsageValidity( + type: json["type"], + days: json["days"], + rule: json["rule"], + ruleAr: json["rule_ar"], + redeemBy: DateTime.parse(json["redeem_by"]), + redeemByDisplay: json["redeem_by_display"], + ); + + Map toJson() => { + "type": type, + "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_display": redeemByDisplay, + }; +} + +class Meta { + int total; + int page; + int perPage; + int pages; + + Meta({ + required this.total, + required this.page, + required this.perPage, + required this.pages, + }); + + factory Meta.fromRawJson(String str) => Meta.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Meta.fromJson(Map json) => Meta( + total: json["total"], + page: json["page"], + perPage: json["per_page"], + pages: json["pages"], + ); + + Map toJson() => { + "total": total, + "page": page, + "per_page": perPage, + "pages": pages, + }; +} \ No newline at end of file diff --git a/lib/features/offers_and_discounts/models/payment_result_request_model.dart b/lib/features/offers_and_discounts/models/payment_result_request_model.dart new file mode 100644 index 00000000..42758f4c --- /dev/null +++ b/lib/features/offers_and_discounts/models/payment_result_request_model.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +class PaymentResultRequestModel { + String status; + String paymentRef; + String amount; + String currency; + + PaymentResultRequestModel({ + required this.status, + required this.paymentRef, + required this.amount, + required this.currency, + }); + + factory PaymentResultRequestModel.fromRawJson(String str) => PaymentResultRequestModel.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PaymentResultRequestModel.fromJson(Map json) => PaymentResultRequestModel( + status: json["status"], + paymentRef: json["payment_ref"], + amount: json["amount"], + currency: json["currency"], + ); + + Map toJson() => { + "status": status, + "payment_ref": paymentRef, + "amount": amount, + "currency": currency, + }; +} + diff --git a/lib/features/offers_and_discounts/models/payment_result_response_model.dart b/lib/features/offers_and_discounts/models/payment_result_response_model.dart new file mode 100644 index 00000000..f518ce76 --- /dev/null +++ b/lib/features/offers_and_discounts/models/payment_result_response_model.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; + +class PaymentResultResponseModel { + String status; + + PaymentResultResponseModel({ + required this.status, + }); + + factory PaymentResultResponseModel.fromRawJson(String str) => PaymentResultResponseModel.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PaymentResultResponseModel.fromJson(Map json) => PaymentResultResponseModel( + status: json["status"], + ); + + Map toJson() => { + "status": status, + }; +} + diff --git a/lib/features/offers_and_discounts/models/poms_auth_token_model.dart b/lib/features/offers_and_discounts/models/poms_auth_token_model.dart new file mode 100644 index 00000000..53b77967 --- /dev/null +++ b/lib/features/offers_and_discounts/models/poms_auth_token_model.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +class PomsAuthTokenModel { + String token; + DateTime expiresAt; + Partner partner; + PortalAccount portalAccount; + + PomsAuthTokenModel({ + required this.token, + required this.expiresAt, + required this.partner, + required this.portalAccount, + }); + + factory PomsAuthTokenModel.fromRawJson(String str) => PomsAuthTokenModel.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PomsAuthTokenModel.fromJson(Map json) => PomsAuthTokenModel( + token: json["token"], + expiresAt: DateTime.parse(json["expires_at"]), + partner: Partner.fromJson(json["partner"]), + portalAccount: PortalAccount.fromJson(json["portal_account"]), + ); + + Map toJson() => { + "token": token, + "expires_at": expiresAt.toIso8601String(), + "partner": partner.toJson(), + "portal_account": portalAccount.toJson(), + }; + + // Helper method to check if token is expired + bool isExpired() { + return DateTime.now().isAfter(expiresAt); + } + + // Helper method to check if token is valid + bool isValid() { + return !isExpired() && token.isNotEmpty; + } +} + +class Partner { + int id; + String name; + + Partner({ + required this.id, + required this.name, + }); + + factory Partner.fromRawJson(String str) => Partner.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Partner.fromJson(Map json) => Partner( + id: json["id"], + name: json["name"], + ); + + Map toJson() => { + "id": id, + "name": name, + }; +} + +class PortalAccount { + bool created; + String login; + + PortalAccount({ + required this.created, + required this.login, + }); + + factory PortalAccount.fromRawJson(String str) => PortalAccount.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PortalAccount.fromJson(Map json) => PortalAccount( + created: json["created"], + login: json["login"], + ); + + Map toJson() => { + "created": created, + "login": login, + }; +} + 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 e175cd23..fb86abe1 100644 --- a/lib/features/offers_and_discounts/offers_and_discounts_repo.dart +++ b/lib/features/offers_and_discounts/offers_and_discounts_repo.dart @@ -5,14 +5,26 @@ import 'package:hmg_patient_app_new/core/common_models/generic_api_model.dart'; import 'package:hmg_patient_app_new/core/exceptions/api_failure.dart'; import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_and_discounts_response_model.dart'; import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_and_discounts_detailed_response_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/poms_auth_token_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offer_purchase_request_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offer_purchase_response_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/payment_result_request_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/payment_result_response_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_purchases_history_response_model.dart'; import 'package:hmg_patient_app_new/services/logger_service.dart'; abstract class OffersAndDiscountsRepo { + Future>> authenticatePatient(String patientId, String mobileNumber); + Future>> getOffersAndDiscounts(); Future>> getOfferDetails(int offerId); - Future>>> getOffersAndDiscountsHistory(); + Future>> getOffersAndDiscountsHistory(String authToken); + + Future>> purchaseOffer(int offerId, OfferPurchaseRequestModel request, String authToken); + + Future>> sendPaymentResult(int orderId, PaymentResultRequestModel request, String authToken); } class OffersAndDiscountsRepoImp implements OffersAndDiscountsRepo { @@ -21,6 +33,57 @@ class OffersAndDiscountsRepoImp implements OffersAndDiscountsRepo { OffersAndDiscountsRepoImp({required this.loggerService, required this.apiClient}); + @override + Future>> authenticatePatient(String patientId, String mobileNumber) async { + Map headers = { + 'Content-Type': 'application/json', + 'X-POMS-CHANNEL-API-KEY': ApiConsts.pomsApiKey, + }; + + Map body = { + 'patient_id': patientId, + 'mobile_number': mobileNumber, + }; + + try { + GenericApiModel? apiResponse; + Failure? failure; + + await apiClient.post( + ApiConsts.pomsAuthPatient, + body: body, + apiHeaders: headers, + isExternal: true, + isAllowAny: true, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = ServerFailure(error); + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + final authResponse = PomsAuthTokenModel.fromJson(response); + + apiResponse = GenericApiModel( + messageStatus: 1, + statusCode: statusCode, + errorMessage: null, + data: authResponse, + ); + } catch (e) { + loggerService.logError("Error parsing POMS auth response: $e"); + failure = DataParsingFailure(e.toString()); + } + }, + ); + + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + loggerService.logError("Exception in authenticatePatient: $e"); + return Left(UnknownFailure(e.toString())); + } + } + @override Future>> getOffersAndDiscounts() async { Map headers = { @@ -109,42 +172,142 @@ class OffersAndDiscountsRepoImp implements OffersAndDiscountsRepo { } @override - Future>>> getOffersAndDiscountsHistory() async { - Map mapDevice = {}; + @override + Future>> getOffersAndDiscountsHistory(String authToken) async { + Map headers = { + 'Content-Type': 'application/json', + 'X-POMS-CHANNEL-API-KEY': ApiConsts.pomsApiKey, + 'Authorization': 'Bearer $authToken', + }; try { - GenericApiModel>? apiResponse; + GenericApiModel? apiResponse; Failure? failure; - await apiClient.post( + + await apiClient.get( ApiConsts.pomsGetOffersAndDiscountPurchasedHistory, - body: mapDevice, + apiHeaders: headers, + isExternal: true, + isAllowAny: true, + onFailure: (error, statusCode) { + failure = ServerFailure(error); + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + final historyResponse = OfferPurchaseHistoryResponseModel.fromJson(response); + + apiResponse = GenericApiModel( + messageStatus: 1, + statusCode: statusCode, + errorMessage: null, + data: historyResponse, + ); + } catch (e) { + loggerService.logError("Error parsing offer purchase history response: $e"); + failure = DataParsingFailure(e.toString()); + } + }, + ); + + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + loggerService.logError("Exception in getOffersAndDiscountsHistory: $e"); + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future>> purchaseOffer( + int offerId, + OfferPurchaseRequestModel request, + String authToken, + ) async { + Map headers = {'Content-Type': 'application/json', 'X-POMS-CHANNEL-API-KEY': ApiConsts.pomsApiKey, 'Authorization': 'Bearer $authToken'}; + + try { + GenericApiModel? apiResponse; + Failure? failure; + + await apiClient.post( + ApiConsts.pomsPurchaseOffer(offerId), + body: request.toJson(), + apiHeaders: headers, + isExternal: true, + isAllowAny: true, onFailure: (error, statusCode, {messageStatus, failureType}) { - failure = failureType; + failure = ServerFailure(error); }, onSuccess: (response, statusCode, {messageStatus, errorMessage}) { try { - final list = response['List_OffersAndDiscountsHistory']; - if (list == null || list.isEmpty) { - throw Exception("offers and discounts history list is empty"); - } + final purchaseResponse = OfferPurchaseResponseModel.fromJson(response); + + apiResponse = GenericApiModel( + messageStatus: 1, + statusCode: statusCode, + errorMessage: null, + data: purchaseResponse, + ); + } catch (e) { + loggerService.logError("Error parsing offer purchase response: $e"); + failure = DataParsingFailure(e.toString()); + } + }, + ); + + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + loggerService.logError("Exception in purchaseOffer: $e"); + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future>> sendPaymentResult( + int orderId, + PaymentResultRequestModel request, + String authToken, + ) async { + Map headers = {'Content-Type': 'application/json', 'X-POMS-CHANNEL-API-KEY': ApiConsts.pomsApiKey, 'Authorization': 'Bearer $authToken'}; - final historyOffers = list.map((item) => OffersAndDiscountsResponseModel.fromJson(item as Map)).toList().cast(); + try { + GenericApiModel? apiResponse; + Failure? failure; - apiResponse = GenericApiModel>( - messageStatus: messageStatus, + await apiClient.post( + ApiConsts.pomsPaymentResult(orderId), + body: request.toJson(), + apiHeaders: headers, + isExternal: true, + isAllowAny: true, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = ServerFailure(error); + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + final paymentResultResponse = PaymentResultResponseModel.fromJson(response); + + apiResponse = GenericApiModel( + messageStatus: 1, statusCode: statusCode, errorMessage: null, - data: historyOffers, + data: paymentResultResponse, ); } catch (e) { + loggerService.logError("Error parsing payment result response: $e"); failure = DataParsingFailure(e.toString()); } }, ); + if (failure != null) return Left(failure!); if (apiResponse == null) return Left(ServerFailure("Unknown error")); return Right(apiResponse!); } catch (e) { + loggerService.logError("Exception in sendPaymentResult: $e"); return Left(UnknownFailure(e.toString())); } } diff --git a/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart b/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart index d0166a9e..29cf4339 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,6 +1,14 @@ import 'package:flutter/material.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'; import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_and_discounts_detailed_response_model.dart' hide Category; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/poms_auth_token_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offer_purchase_request_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offer_purchase_response_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/payment_result_request_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/models/payment_result_response_model.dart'; +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'; @@ -8,6 +16,8 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { bool isOffersLoading = false; bool isHistoryLoading = false; bool isOfferDetailsLoading = false; + bool isAuthenticating = false; + bool isPurchasing = false; List selectedCategories = []; // Empty means 'All Offers' String searchQuery = ''; @@ -15,10 +25,12 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { ErrorHandlerService errorHandlerService; List offersList = []; - List historyList = []; + history_model.OfferPurchaseHistoryResponseModel? purchaseHistory; OffersAndDiscountsDetailedResponseModel? offerDetails; OffersAndDiscountsDetailedResponseModel? selectedOffer; Meta? meta; + PomsAuthTokenModel? pomsAuthToken; + OfferPurchaseResponseModel? lastPurchaseResponse; OffersAndDiscountsViewModel({ required this.offersAndDiscountsRepo, @@ -28,11 +40,27 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { // For loading state compatibility with existing code bool get isRegionListLoading => isOffersLoading; - initOffersAndDiscounts() { + initOffersAndDiscounts({bool forceRefresh = false}) { + // Only clear data if force refresh, otherwise keep existing data + if (forceRefresh) { + offersList.clear(); + } + + // Only show loading if we don't have data yet + if (offersList.isEmpty) { + isOffersLoading = true; + notifyListeners(); + } + + getOffersAndDiscounts(); + } + + // Method to force refresh the offers list + Future refreshOffersAndDiscounts({Function(dynamic)? onSuccess, Function(String)? onError}) async { offersList.clear(); isOffersLoading = true; notifyListeners(); - getOffersAndDiscounts(); + await getOffersAndDiscounts(onSuccess: onSuccess, onError: onError); } setIsOffersLoading(bool val) { @@ -45,6 +73,204 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { notifyListeners(); } + setIsAuthenticating(bool val) { + isAuthenticating = val; + notifyListeners(); + } + + /// Authenticate patient before making a purchase + Future authenticatePatient({Function(PomsAuthTokenModel)? onSuccess, Function(String)? onError}) async { + final appState = getIt.get(); + final authenticatedUser = appState.getAuthenticatedUser(); + + if (authenticatedUser == null) { + if (onError != null) { + onError("User not authenticated"); + } + return false; + } + + // Check if token is already valid + if (appState.isPomsAuthTokenValid()) { + if (onSuccess != null && pomsAuthToken != null) { + onSuccess(pomsAuthToken!); + } + return true; + } + + isAuthenticating = true; + notifyListeners(); + + final result = await offersAndDiscountsRepo.authenticatePatient( + authenticatedUser.patientId.toString(), + // "4774103", + // "2001273", + authenticatedUser.mobileNumber ?? "", + ); + + result.fold( + (failure) async { + isAuthenticating = false; + notifyListeners(); + await errorHandlerService.handleError(failure: failure); + if (onError != null) { + onError(failure.toString()); + } + }, + (response) { + isAuthenticating = false; + if (response.data != null) { + pomsAuthToken = response.data; + // Store token in AppState + appState.setPomsAuthToken(response.data!.token, response.data!.expiresAt); + } + notifyListeners(); + if (onSuccess != null && response.data != null) { + onSuccess(response.data!); + } + }, + ); + + return pomsAuthToken != null && pomsAuthToken!.isValid(); + } + + /// Ensure patient is authenticated, re-authenticate if token is invalid or expired + /// This is a convenience method for Buy Now buttons and other actions requiring auth + Future ensureAuthenticated({ + Function()? onSuccess, + Function(String)? onError, + }) async { + final appState = getIt.get(); + + // Check if token is already valid + if (appState.isPomsAuthTokenValid()) { + if (onSuccess != null) { + onSuccess(); + } + return true; + } + + // Token is invalid or expired, re-authenticate + final isAuthenticated = await authenticatePatient( + onSuccess: (authToken) { + print("POMS Authentication successful: ${authToken.token}"); + if (onSuccess != null) { + onSuccess(); + } + }, + onError: (error) { + print("POMS Authentication failed: $error"); + if (onError != null) { + onError(error); + } + }, + ); + + return isAuthenticated; + } + + /// Purchase an offer after payment is successful + Future purchaseOffer({ + required int offerId, + required String identificationType, + required String idValue, + required String mobileNumber, + Function(OfferPurchaseResponseModel)? onSuccess, + Function(String)? onError, + }) async { + final appState = getIt.get(); + + // Ensure we have a valid auth token + if (!appState.isPomsAuthTokenValid()) { + if (onError != null) { + onError("Authentication token is invalid or expired"); + } + return false; + } + + isPurchasing = true; + notifyListeners(); + + final request = OfferPurchaseRequestModel(identificationType: identificationType, idValue: idValue, mobileNumber: mobileNumber); + + final result = await offersAndDiscountsRepo.purchaseOffer(offerId, request, appState.getPomsAuthToken); + + result.fold( + (failure) async { + isPurchasing = false; + notifyListeners(); + await errorHandlerService.handleError(failure: failure); + if (onError != null) { + onError(failure.toString()); + } + }, + (response) { + isPurchasing = false; + if (response.data != null) { + lastPurchaseResponse = response.data; + print(lastPurchaseResponse!.toRawJson()); + } + notifyListeners(); + if (onSuccess != null && response.data != null) { + onSuccess(response.data!); + } + }, + ); + + return lastPurchaseResponse != null; + } + + /// Send payment result to backend after successful payment + Future sendPaymentResult({ + required int orderId, + required String status, + required String paymentRef, + required String amount, + required String currency, + Function(PaymentResultResponseModel)? onSuccess, + Function(String)? onError, + }) async { + final appState = getIt.get(); + + // Ensure we have a valid auth token + if (!appState.isPomsAuthTokenValid()) { + if (onError != null) { + onError("Authentication token is invalid or expired"); + } + return false; + } + + final request = PaymentResultRequestModel( + status: status, + paymentRef: paymentRef, + amount: amount, + currency: currency, + ); + + final result = await offersAndDiscountsRepo.sendPaymentResult(orderId, request, appState.getPomsAuthToken); + + bool success = false; + + result.fold( + (failure) async { + await errorHandlerService.handleError(failure: failure); + if (onError != null) { + onError(failure.toString()); + } + }, + (response) { + if (response.data != null) { + success = response.data!.status.toLowerCase() == "ok"; + } + if (onSuccess != null && response.data != null) { + onSuccess(response.data!); + } + }, + ); + + return success; + } + setSelectedCategories(List categoryIds) { selectedCategories = categoryIds; notifyListeners(); @@ -75,8 +301,13 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { } Future getOffersAndDiscounts({Function(dynamic)? onSuccess, Function(String)? onError}) async { - isOffersLoading = true; - notifyListeners(); + // Only show loading if we don't have data yet + final bool hasExistingData = offersList.isNotEmpty; + + if (!hasExistingData) { + isOffersLoading = true; + notifyListeners(); + } final result = await offersAndDiscountsRepo.getOffersAndDiscounts(); @@ -84,7 +315,7 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { (failure) async { isOffersLoading = false; notifyListeners(); - // Keep dummy data if API fails - don't clear the list + // Keep existing data if API fails await errorHandlerService.handleError(failure: failure); if (onError != null) { onError(failure.toString()); @@ -93,10 +324,22 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { (response) { isOffersLoading = false; if (response.data != null) { - offersList = response.data!.data; + // If we already have data, append new data instead of replacing + 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(); + + // Append only new items + if (newItems.isNotEmpty) { + offersList.addAll(newItems); + } + } else { + // First load - replace the list + offersList = response.data!.data; + } meta = response.data!.meta; } - // If API returns empty or null, keep the dummy data notifyListeners(); if (onSuccess != null) { onSuccess(response); @@ -134,27 +377,37 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { } Future getOffersAndDiscountsHistory({Function(dynamic)? onSuccess, Function(String)? onError}) async { + final appState = getIt.get(); + + // Ensure we have a valid auth token + if (!appState.isPomsAuthTokenValid()) { + isHistoryLoading = false; + notifyListeners(); + if (onError != null) { + onError("Authentication token is invalid or expired"); + } + return; + } + isHistoryLoading = true; notifyListeners(); - final result = await offersAndDiscountsRepo.getOffersAndDiscountsHistory(); + final result = await offersAndDiscountsRepo.getOffersAndDiscountsHistory(appState.getPomsAuthToken); result.fold( (failure) async { isHistoryLoading = false; notifyListeners(); - // Keep dummy data if API fails - don't clear the list - // await errorHandlerService.handleError(failure: failure); + await errorHandlerService.handleError(failure: failure); if (onError != null) { onError(failure.toString()); } }, (response) { isHistoryLoading = false; - if (response.data != null && response.data!.isNotEmpty) { - historyList = response.data!; + if (response.data != null) { + purchaseHistory = response.data; } - // If API returns empty or null, keep the dummy data notifyListeners(); if (onSuccess != null) { onSuccess(response); @@ -163,6 +416,10 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { ); } + List get historyList { + return purchaseHistory?.data ?? []; + } + List get filteredOffers { List filtered = offersList; @@ -185,14 +442,16 @@ class OffersAndDiscountsViewModel extends ChangeNotifier { return filtered; } - List get filteredHistory { - List filtered = historyList; + List get filteredHistory { + List filtered = historyList; // Filter by search query if (searchQuery.isNotEmpty) { - filtered = filtered.where((offer) { - // Assuming history list uses the old structure - update when API is available - return true; // Placeholder for now + 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()); + return offerNameMatch || offerNameArMatch || voucherCodeMatch; }).toList(); } diff --git a/lib/presentation/home/landing_page.dart b/lib/presentation/home/landing_page.dart index 531b310a..440aaf6f 100644 --- a/lib/presentation/home/landing_page.dart +++ b/lib/presentation/home/landing_page.dart @@ -31,6 +31,7 @@ import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/ import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/patient_queue_details_response_model.dart'; import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart'; import 'package:hmg_patient_app_new/features/notifications/notifications_view_model.dart'; +import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_view_model.dart'; import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart'; import 'package:hmg_patient_app_new/features/paytabs/paytabs_view_model.dart'; import 'package:hmg_patient_app_new/features/todo_section/todo_section_view_model.dart'; @@ -99,6 +100,7 @@ class _LandingPageState extends State { late EmergencyServicesViewModel emergencyServicesViewModel; late TodoSectionViewModel todoSectionViewModel; late NotificationsViewModel notificationsViewModel; + late OffersAndDiscountsViewModel offersAndDiscountsViewModel; final SwiperController _controller = SwiperController(); @@ -159,6 +161,7 @@ class _LandingPageState extends State { // myAppointmentsViewModel.getPatientAppointmentQueueDetails(); notificationsViewModel.initNotificationsViewModel(); insuranceViewModel.initInsuranceProvider(); + offersAndDiscountsViewModel.initOffersAndDiscounts(); if (!appState.isRatedVisible) { appointmentRatingViewModel.getLastRatingAppointment( @@ -197,6 +200,7 @@ class _LandingPageState extends State { emergencyServicesViewModel = Provider.of(context, listen: false); todoSectionViewModel = Provider.of(context, listen: false); notificationsViewModel = Provider.of(context, listen: false); + offersAndDiscountsViewModel = Provider.of(context, listen: false); appState = getIt.get(); return PopScope( canPop: false, @@ -244,7 +248,6 @@ class _LandingPageState extends State { // } // }, // ); - } }, child: SingleChildScrollView( @@ -448,7 +451,8 @@ class _LandingPageState extends State { : SizedBox.shrink(), // Offers And Discounts Carousel - Auto-scrolling from right to left - appState.isAuthenticated && appState.isEnabledOffersAndDiscountsCarousel + //appState.isAuthenticated && + appState.isEnabledOffersAndDiscountsCarousel && offersAndDiscountsViewModel.offersList.isNotEmpty ? Column( children: [ SizedBox(height: 12.h), @@ -536,9 +540,7 @@ class _LandingPageState extends State { height: isTablet ? 290.h : 255.h, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: myAppointmentsVM.patientAppointmentsHistoryList.length < 3 - ? myAppointmentsVM.patientAppointmentsHistoryList.length - : 3, + itemCount: myAppointmentsVM.patientAppointmentsHistoryList.length < 3 ? myAppointmentsVM.patientAppointmentsHistoryList.length : 3, shrinkWrap: true, padding: EdgeInsets.only(left: 16.h, right: 16.h), itemBuilder: (context, index) { 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 6be433a7..bfccc7c0 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 @@ -8,12 +8,15 @@ 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'; import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; +import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:easy_localization/easy_localization.dart'; class OfferAndDiscountsFullScreenSwiperPage extends StatefulWidget { final int offerId; @@ -318,7 +321,28 @@ class _OfferAndDiscountsFullScreenSwiperPageState extends State createState() => _OffersAndDiscountsSuccessQrPageState(); +} + +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(); + } + } + + Color _getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'active': + case 'valid': + return AppColors.successColor; + case 'redeemed': + case 'used': + return AppColors.warningColor; + case 'expired': + return AppColors.errorColor; + default: + return AppColors.greyTextColor; + } + } + + 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); + default: + return status.toUpperCase(); + } + } + + void _copyVoucherCode() { + Clipboard.setData(ClipboardData(text: widget.purchaseData.voucherCode)); + Utils.showToast("Voucher code copied to clipboard"); + } + + @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)), + ), + 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: 16.h), + widget.purchaseData.voucherCode.toText20(isBold: true, weight: FontWeight.w700), + SizedBox(height: 8.h), + GestureDetector( + onTap: _copyVoucherCode, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.copy, size: 16.h, color: AppColors.primaryRedColor), + SizedBox(width: 4.w), + "Copy".toText12(color: AppColors.primaryRedColor), + ], + ), + ), + ], + ), + ), + 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, + 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), + ), + ), + ], + ), + 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), + ], + ), + ), + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: label.toText14(color: AppColors.greyTextColor), + ), + SizedBox(width: 16.w), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: value.toText14(isBold: true), + ), + ), + ], + ); + } +} 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 ee08109a..6bdcce64 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 @@ -22,7 +22,7 @@ import 'package:hmg_patient_app_new/features/payfort/payfort_view_model.dart'; import 'package:hmg_patient_app_new/features/paytabs/models/paytabs_transaction_response_model.dart'; import 'package:hmg_patient_app_new/features/paytabs/paytabs_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; -import 'package:hmg_patient_app_new/presentation/home/navigation_screen.dart'; +import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offer_and_discounts_success_payment_qr_page.dart'; import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; @@ -47,7 +47,6 @@ class _OffersAndDiscountPaymentPageState extends State createOrder() async { + final selectedOffer = offersAndDiscountsViewModel.selectedOffer; + final authenticatedUser = appState.getAuthenticatedUser(); + + if (selectedOffer == null || authenticatedUser == null) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: "Unable to create order. Missing offer or user information."), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + return null; + } + + // Determine identification type based on patient identification type + String identificationType = "national_id"; // Default + // if (authenticatedUser.patientIdentificationType != null) { + // switch (authenticatedUser.patientIdentificationType) { + // case 1: + // identificationType = "national_id"; + // break; + // case 2: + // identificationType = "iqama"; + // break; + // case 3: + // identificationType = "passport"; + // break; + // case 4: + // identificationType = "gcc_id"; + // break; + // default: + // identificationType = "national_id"; + // } + // } + + String? orderRef; + await offersAndDiscountsViewModel.purchaseOffer( + offerId: selectedOffer.id, + identificationType: identificationType, + // idValue: "2011594252", + // idValue: "4774103", + idValue: authenticatedUser.patientIdentificationNo ?? "", + mobileNumber: authenticatedUser.mobileNumber ?? "", + onSuccess: (purchaseResponse) { + // Get the order_ref from the purchase response + orderRef = purchaseResponse.orderRef; + }, + onError: (error) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: "Failed to create order. Please try again."), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }, + ); + + return orderRef; + } + + /// Show payment success message + Future completePaymentResult({required String paymentStatus}) async { + final selectedOffer = offersAndDiscountsViewModel.selectedOffer; + final lastPurchaseResponse = offersAndDiscountsViewModel.lastPurchaseResponse; + + if (selectedOffer == null || lastPurchaseResponse == null) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: "Unable to complete payment. Missing order information."), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + return; + } + + // Call the payment result API + await offersAndDiscountsViewModel.sendPaymentResult( + orderId: lastPurchaseResponse.orderId, + status: paymentStatus, + paymentRef: transID, + amount: selectedOffer.vatExclusivePrice.toString(), + currency: selectedOffer.currency, + onSuccess: (response) async { + // Payment result sent successfully, now fetch purchase history + await offersAndDiscountsViewModel.getOffersAndDiscountsHistory( + onSuccess: (historyResponse) { + LoaderBottomSheet.hideLoader(); + + // Find the purchased offer in the history by matching the offer ID + final purchaseData = offersAndDiscountsViewModel.historyList.firstWhere( + (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), + ), + ); + }, + onError: (error) { + // If history fetch fails, still show success message + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getSuccessWidget(loadingText: LocaleKeys.paymentSuccessful.tr(context: context)), + callBackFunc: () { + Navigator.of(context).popUntil((route) => route.isFirst); + }, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }, + ); + }, + onError: (error) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: "Payment recorded but confirmation failed. Please contact support."), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: 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 3b5e0aef..923fbae9 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 @@ -17,6 +17,7 @@ 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'; @@ -99,7 +100,7 @@ class _OffersAndDiscountsDetailedPageState extends State(context, listen: false); return CollapsingListView( - title: '${LocaleKeys.order.tr(context: context)} ${LocaleKeys.history.tr(context: context)}', + title: LocaleKeys.order.tr(context: context), child: SingleChildScrollView( child: Consumer(builder: (context, offersAndDiscountVM, child) { + if (offersAndDiscountVM.isHistoryLoading) { + return Container( + padding: EdgeInsets.all(24.h), + child: Center( + child: CircularProgressIndicator( + color: AppColors.primaryRedColor, + ), + ), + ); + } + + final 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 Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Temporarily show a message that history is not available yet - Container( - padding: EdgeInsets.all(24.h), - child: Column( - children: [ - Icon( - Icons.history, - size: 64.h, - color: AppColors.greyTextColor, - ), - SizedBox(height: 16.h), - Text( - 'Order history coming soon', - style: TextStyle( - fontSize: 18.f, - fontWeight: FontWeight.w700, - color: AppColors.textColor, - ), - ), - SizedBox(height: 8.h), - Text( - 'This feature will be available once the history API is ready.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.f, - color: AppColors.greyTextColor, - ), - ), - ], - ), + SizedBox(height: 24.h), + ListView.separated( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: historyList.length, + 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); + }, ), SizedBox(height: 24.h), ], @@ -80,4 +186,94 @@ class _OffersAndDiscountsHistoryPageState extends State { @override void initState() { - scheduleMicrotask(() { - offersAndDiscountsViewModel.initOffersAndDiscounts(); - }); super.initState(); + appState = getIt.get(); + scheduleMicrotask(() async { + offersAndDiscountsViewModel = Provider.of(context, listen: false); + + // Authenticate patient once when visiting the offers page + if (!appState.isPomsAuthTokenValid()) { + await offersAndDiscountsViewModel.authenticatePatient( + onSuccess: (authToken) { + print("POMS Authentication successful: ${authToken.token}"); + }, + onError: (error) { + print("POMS Authentication failed: $error"); + // Authentication failed, but user can still browse offers + }, + ); + } + + // Load offers only if we don't have data yet (prevents double API call) + if (offersAndDiscountsViewModel.offersList.isEmpty) { + offersAndDiscountsViewModel.initOffersAndDiscounts(); + } + }); } @override @@ -294,12 +314,37 @@ class _OffersAndDiscountsPageState extends State { labelPadding: EdgeInsetsDirectional.only(start: 4.w, end: 8.w), ), SizedBox(height: 20.h), - CustomButton( + CustomButton( text: 'Buy Now', icon: AppAssets.shoppingCart, iconColor: AppColors.whiteColor, onPressed: () async { LoaderBottomSheet.showLoader(loadingText: LocaleKeys.pleaseWait.tr(context: context)); + + // Ensure patient is authenticated (will re-authenticate if needed) + final isAuthenticated = await offersAndDiscountVM.ensureAuthenticated( + onSuccess: () { + // Authentication successful, continue + }, + onError: (error) { + LoaderBottomSheet.hideLoader(); + // Show error to user + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: LocaleKeys.paymentFailedPleaseTryAgain.tr(context: context)), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }, + ); + + if (!isAuthenticated) { + LoaderBottomSheet.hideLoader(); + return; + } + + // Fetch offer details await offersAndDiscountVM.getOfferDetails( offersAndDiscountVM.filteredOffers[index].id, onSuccess: (response) { 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 37492185..211fed80 100644 --- a/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart +++ b/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart @@ -30,10 +30,13 @@ class _OffersAndDiscountsCarouselState extends State void initState() { super.initState(); appState = getIt.get(); - // Initialize the view model and load offers data + // Initialize the view model and load offers data only if not already loaded WidgetsBinding.instance.addPostFrameCallback((_) { final viewModel = Provider.of(context, listen: false); - viewModel.initOffersAndDiscounts(); + // Only initialize if we don't have data yet + if (viewModel.offersList.isEmpty) { + viewModel.initOffersAndDiscounts(); + } }); // _startAutoScroll(); }