From c38d7abbf3470bf46484a33df8c944917ea55513 Mon Sep 17 00:00:00 2001 From: Bradley Venn Date: Fri, 1 May 2026 19:20:44 +0100 Subject: [PATCH 1/2] Refactor checkout flow to create orders after PaymentSheet --- android/app/build.gradle.kts | 7 +- lib/core/config/app_strings.dart | 82 ++-- lib/core/services/firebase_auth_service.dart | 2 +- lib/core/services/network/api_service.dart | 23 +- lib/features/charity_page/charity_model.dart | 1 - lib/features/checkout/checkout_page.dart | 80 ++-- .../checkout/checkout_repository.dart | 10 +- .../checkout/checkout_view_model.dart | 436 +++++++++++++----- .../checkout/models/payment_intent.dart | 9 +- .../checkout/models/payment_intent.g.dart | 28 +- .../widgets/donation_dropdown_field.dart | 2 +- .../donation/widgets/photo_tips_bar.dart | 1 - .../widgets/hidden_password_field.dart | 1 - lib/features/products/product_page.dart | 2 +- lib/features/profile/profile_page.dart | 2 +- lib/main.dart | 2 +- test/auth_ui_test.mocks.dart | 219 +++++++++ test/checkout_view_model_test.dart | 177 +++++++ test/checkout_view_model_test.mocks.dart | 111 +++++ 19 files changed, 956 insertions(+), 239 deletions(-) create mode 100644 test/auth_ui_test.mocks.dart create mode 100644 test/checkout_view_model_test.mocks.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6a9bdcfe..0e4e06e1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -7,6 +7,7 @@ val localPropertiesFile = rootDir.resolve("local.properties") if (localPropertiesFile.exists()) { localProperties.load(FileInputStream(localPropertiesFile)) } + plugins { id("com.android.application") // START: FlutterFire Configuration @@ -20,7 +21,7 @@ plugins { android { namespace = "com.example.cherry_mvp" compileSdk = flutter.compileSdkVersion - ndkVersion = localProperties.getProperty("flutter.ndkVersion") + ndkVersion = flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -38,8 +39,8 @@ android { applicationId = "com.example.cherry_mvp" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 25 - targetSdk = 34 + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } diff --git a/lib/core/config/app_strings.dart b/lib/core/config/app_strings.dart index 75bab2b7..c6ca6fdd 100644 --- a/lib/core/config/app_strings.dart +++ b/lib/core/config/app_strings.dart @@ -23,14 +23,12 @@ class AppStrings { static const continueWithApple = 'Continue with Apple'; static const continueWithGoogle = 'Continue with Google'; static const forgotPassword = 'Forgot Password?'; - static const forgotPasswordInstruction = - 'Please type your email and we’ll get in touch'; + static const forgotPasswordInstruction = 'Please type your email and we’ll get in touch'; static const usernameSetupTitle = 'Who is this?'; static const usernameSetupSubtitle = 'Enter the name you will like to go by.'; static const usernameSetupHint = 'Username'; static const usernameTakenError = 'Username is already taken.'; - static const usernameSaveFailed = - 'Could not save username. Please try again.'; + static const usernameSaveFailed = 'Could not save username. Please try again.'; static const email = 'Email'; static const sendEmail = 'Send email'; static const userCheck = 'Not you'; @@ -72,8 +70,7 @@ class AppStrings { static const profileUserActivityTotal = 'Total'; static const profileUserDonationSummary = 'User Donation Summary'; static const profileYourDonationImpact = 'Your Donation Impact'; - static const profileGenerosityChangesLives = - 'See how your generosity changes lives'; + static const profileGenerosityChangesLives = 'See how your generosity changes lives'; static const profileUserDonationTotal = 'Total'; static const profileUserDonationTotalAmount = 'Total Amount'; static const profileUserDonationTotalItems = 'Total Items Donated'; @@ -81,14 +78,10 @@ class AppStrings { static const profileUserDonationTotalItemsBought = 'Total Items Bought'; static const profileUserDonationTotalItemsReceived = 'Total Items Received'; static const profileUserDonationTotalItemsGiven = 'Total Items Given'; - static const profileUserDonationTotalItemsReceivedFromOthers = - 'Total Items Received from Others'; - static const profileUserDonationTotalItemsGivenToOthers = - 'Total Items Given to Others'; - static const profileUserDonationTotalItemsSoldToOthers = - 'Total Items Sold to Others'; - static const profileUserDonationTotalItemsBoughtFromOthers = - 'Total Items Bought from Others'; + static const profileUserDonationTotalItemsReceivedFromOthers = 'Total Items Received from Others'; + static const profileUserDonationTotalItemsGivenToOthers = 'Total Items Given to Others'; + static const profileUserDonationTotalItemsSoldToOthers = 'Total Items Sold to Others'; + static const profileUserDonationTotalItemsBoughtFromOthers = 'Total Items Bought from Others'; // Home static const charityOfText = "Charity of"; @@ -135,8 +128,7 @@ class AppStrings { static const multipleAngles = "Show multiple angles, including front view, back view, tags & labels, close-up details and any flaws."; static const noFilters = "No wacky filters or over-editing."; - static const showItOff = - "If you’re comfortable, get it on & show it off! If not, something for reference is great!"; + static const showItOff = "If you’re comfortable, get it on & show it off! If not, something for reference is great!"; static const learnHow = "Learn How"; static const thoughtsOnUpload = "Thoughts on our upload process?"; static const giveFeedback = "Give feedback"; @@ -144,14 +136,11 @@ class AppStrings { // Donation static const donationsText = "Donate"; static const donationOptionsText = "Donation Options"; - static const giveYourBuyerText = - "Give your buyer the option pick a cause they care about."; - static const easyWayText = - "Its an easy way to make your listing more impactful."; + static const giveYourBuyerText = "Give your buyer the option pick a cause they care about."; + static const easyWayText = "Its an easy way to make your listing more impactful."; static const openToOtherCharitiesText = "Open to other charities"; static const openToOffersText = "Open to offers"; - static const applicableForBuyerDiscountsText = - "Applicable for donor discounts"; + static const applicableForBuyerDiscountsText = "Applicable for donor discounts"; static const donorDiscountsActiveText = "Donor discounts active"; static const donorDiscountsInactiveText = "Donor discounts inactive"; @@ -159,8 +148,7 @@ class AppStrings { static const titleText = "Title"; static const descriptionText = "Description"; static const categoryText = "Category"; - static const charityDescription = - 'Supporting communities through various charitable initiatives and programs.'; + static const charityDescription = 'Supporting communities through various charitable initiatives and programs.'; static const charityText = "Charity"; static const selectOptionText = 'Tap to choose'; static const charityError = 'Failed to load charities'; @@ -181,8 +169,7 @@ class AppStrings { static const submitDonation = 'Submit Donation'; // Donation Submission - static const donationSubmittedSuccessfully = - 'Donation submitted successfully!'; + static const donationSubmittedSuccessfully = 'Donation submitted successfully!'; static const unexpectedErrorOccurred = 'An unexpected error occurred'; static const userNotAuthenticated = 'User not authenticated'; static const failedToUploadImages = 'Failed to upload images'; @@ -190,15 +177,13 @@ class AppStrings { // Donation Success static const congratulations = 'Congratulations!'; - static const itemUploadedSuccessfully = - 'Your Item has been Successfully Uploaded.'; + static const itemUploadedSuccessfully = 'Your Item has been Successfully Uploaded.'; // Product Page static const productPageDescription = "Description"; static const productPageBuyerDiscountActive = 'Donor discounts active'; static const productPageDonorDiscountInactive = 'Donor discounts inactive'; - static const productPageDonorDiscountInactiveDetail = - 'Donor Discounts Currently Inactive'; + static const productPageDonorDiscountInactiveDetail = 'Donor Discounts Currently Inactive'; static const productPageBuy2Get1HalfPrice = 'Buy 2 get 1 half price'; static const productPageOpenToOtherCharities = 'Open to other charities'; static const productPageRequestOtherCharity = 'Request other charity'; @@ -238,10 +223,19 @@ class AppStrings { static const checkoutImpactSummary = 'Impact Summary'; static const checkoutReview = 'Review'; static const checkoutContinueShopping = 'Continue Shopping'; - static const checkoutDeliveryOptionRequired = - 'Please choose a delivery option'; + static const checkoutDeliveryOptionRequired = 'Please choose a delivery option'; static const checkoutPickupLockerRequired = 'Please select a pickup locker'; static const checkoutPaymentMethodRequired = 'Please select a payment method'; + static const checkoutDeliveryAddressRequired = 'Please confirm your delivery address'; + static const checkoutPaymentFailed = 'Payment could not be completed. Please try again.'; + static const checkoutPaymentCancelled = 'Payment was cancelled.'; + static const checkoutPaymentDetailsUnavailable = 'Payment details could not be confirmed. Please try again.'; + static const checkoutOrderCreationFailed = 'We could not create your order after payment. Please try again.'; + static const checkoutShipmentFailed = + 'Your order was created, but delivery could not be confirmed. Please contact support if this continues.'; + static const checkoutShipmentPending = 'Payment complete. Your order is placed and delivery is still being arranged.'; + static const checkoutRetryOrder = 'Retry order'; + static const checkoutDeliveryPending = 'Delivery pending'; // Card Details static const cardDetailsTitle = 'Card Details'; @@ -259,8 +253,7 @@ class AppStrings { // Payment Methods static const paymentMethodsTitle = 'Payment Methods'; - static const paymentMethodsInfo = - 'Your payment information will never be shared with the seller'; + static const paymentMethodsInfo = 'Your payment information will never be shared with the seller'; static const paymentMethodsChoose = 'Choose a payment method'; static const paymentMethodsCard = 'Card'; static const paymentMethodsGooglePay = 'Pay with Google Pay'; @@ -269,14 +262,12 @@ class AppStrings { // Purchase Security static const purchaseSecurityHeading = "Purchase Security"; - static const purchaseSecuritySubHeading = - "Learn how we calculate the Purchase Security fee"; + static const purchaseSecuritySubHeading = "Learn how we calculate the Purchase Security fee"; static const purchaseSecurityButtonText = "Got it"; // Refund Policy static const refundPolicyHeading = "Refund policy"; - static const refundPolicyConditionsHeading = - "You can receive a refund if your order:"; + static const refundPolicyConditionsHeading = "You can receive a refund if your order:"; static const refundPolicyConditionOne = "was never shipped or is lost"; static const refundPolicyConditionTwo = "arrives damaged"; static const refundPolicyConditionThree = "is significantly not as described"; @@ -296,20 +287,17 @@ class AppStrings { "Failing this, all funds will be held securely in escrow for 7 days, then 100% of the funds will be released to the charity."; static const secureTransactionsBodyTwo = "Payments are encrypted by our payment partner, so your money is always sent and received safely."; - static const secureTransactionsBodyThreeBold = - "The seller will never see your payment details."; + static const secureTransactionsBodyThreeBold = "The seller will never see your payment details."; // Support static const yourSupportHeading = "Your Support"; static const yourSupportBody = "Reach out to our support at any time with any issues, disputes questions, guidance or anything else- they’re here to help!"; static const supportMissionHeading = "Support our mission"; - static const supportMissionBoldTextSpan = - "cherry is a CIC (Community Interest Company) limited by guarantee, "; + static const supportMissionBoldTextSpan = "cherry is a CIC (Community Interest Company) limited by guarantee, "; static const supportMissionNormalTextSpan = "NOT by shares."; static const supportMissionPlainText = "Meaning "; - static const supportMissionPinkText = - "we will NEVER work in the interests of profiteers."; + static const supportMissionPinkText = "we will NEVER work in the interests of profiteers."; static const supportMissionBodyText = "When you buy on cherry, you’re helping in our mission to support charities online, " "combat fast fashion, reduce waste, maintain a transparent platform and so much more! Thank you."; @@ -353,8 +341,7 @@ class AppStrings { static const city = 'City'; static const useAsDefaultAddress = 'Use as my Default address'; static const wantToShareLocation = 'Do you want to share your location?'; - static const noPickupPointAvailable = - 'Pickup points currently unavailable, please try again later'; + static const noPickupPointAvailable = 'Pickup points currently unavailable, please try again later'; static const loadingPickupPoint = 'Loading Pickup point...'; static const failedToLoadPickupPoint = 'Failed to load Pickup point'; @@ -397,8 +384,7 @@ class AppStrings { static const purchaseSecurityAnswer = 'Purchase Security is a mandatory fee (typically 3-8% of the item price before postage or kept between 6-8% of the total cost ) paid on each transaction to safeguard your money and ensure information is protected. It provides protection for your purchases, extra measures against fraud, and support from the Cherry team.'; static const trustAndSafety = 'Trust & Safety'; - static const transactionSafetyQuestion = - 'How does cherry keep transactions safe?'; + static const transactionSafetyQuestion = 'How does cherry keep transactions safe?'; static const transactionSafetyAnswer = 'Cherry maintains a high standard of security through proactive tools and policies. Key safety measures include: Purchase Security (mandatory Buyer Protection) , Secure Transactions (all payments are securely held and encrypted) , Anti-Spam Tools , Data Protection , and Risk Scoring (predictive analysis to identify suspicious activities).'; static const refundPolicyQuestion = 'What’s the refund policy?'; diff --git a/lib/core/services/firebase_auth_service.dart b/lib/core/services/firebase_auth_service.dart index 6aa401aa..a757f8c4 100644 --- a/lib/core/services/firebase_auth_service.dart +++ b/lib/core/services/firebase_auth_service.dart @@ -12,7 +12,7 @@ class FirebaseAuthService { FirebaseAuthService({required this.firebaseAuth}); - late GoogleAuthService _googleAuthService = GoogleAuthService(); + late final GoogleAuthService _googleAuthService = GoogleAuthService(); Future> signUp(String email, String password) async { try { diff --git a/lib/core/services/network/api_service.dart b/lib/core/services/network/api_service.dart index 6a0627f0..22ce5a0b 100644 --- a/lib/core/services/network/api_service.dart +++ b/lib/core/services/network/api_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cherry_mvp/core/config/environment_config.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:cherry_mvp/core/services/error_string.dart'; import 'package:cherry_mvp/core/utils/result.dart'; import 'package:firebase_auth/firebase_auth.dart'; @@ -84,15 +85,17 @@ class DioApiService implements ApiService { ), ); - _dio.interceptors.add( - PrettyDioLogger( - requestHeader: true, - requestBody: true, - responseBody: true, - error: true, - compact: true, - ), - ); + if (kDebugMode) { + _dio.interceptors.add( + PrettyDioLogger( + requestHeader: false, + requestBody: false, + responseBody: false, + error: true, + compact: true, + ), + ); + } } @override @@ -177,7 +180,7 @@ class DioApiService implements ApiService { final statusCode = e.response?.statusCode; final data = e.response?.data; - _log.warning('Bad response: $statusCode - $data'); + _log.warning('Bad response: $statusCode'); switch (statusCode) { case 400: diff --git a/lib/features/charity_page/charity_model.dart b/lib/features/charity_page/charity_model.dart index 7f3c989b..6db801f8 100644 --- a/lib/features/charity_page/charity_model.dart +++ b/lib/features/charity_page/charity_model.dart @@ -1,4 +1,3 @@ -import 'package:cherry_mvp/core/models/donation_charity_model.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:cherry_mvp/core/config/config.dart'; diff --git a/lib/features/checkout/checkout_page.dart b/lib/features/checkout/checkout_page.dart index 7af7a83b..b54cf171 100644 --- a/lib/features/checkout/checkout_page.dart +++ b/lib/features/checkout/checkout_page.dart @@ -3,10 +3,8 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:provider/provider.dart'; import 'package:cherry_mvp/core/config/app_colors.dart'; import 'package:cherry_mvp/core/config/app_strings.dart'; -import 'package:cherry_mvp/core/router/nav_routes.dart'; import 'package:cherry_mvp/core/utils/status.dart'; import 'package:cherry_mvp/features/checkout/checkout_view_model.dart'; -import 'package:cherry_mvp/features/checkout/payment_type.dart'; import 'package:cherry_mvp/features/checkout/widgets/basket_list_item.dart'; import 'package:cherry_mvp/features/checkout/widgets/delivery_options.dart'; import 'package:cherry_mvp/features/checkout/widgets/select_payment_type_bottom_sheet.dart'; @@ -53,7 +51,11 @@ class _CheckoutPageState extends State { } if (status == StatusType.success) { - Fluttertoast.showToast(msg: "Payment Successful"); + Fluttertoast.showToast( + msg: vm.checkoutFlowState == CheckoutFlowState.shipmentPending + ? AppStrings.checkoutShipmentPending + : AppStrings.checkoutOrderPlaced, + ); vm.resetCreateOrderStatus(); vm.gotoCheckoutComplete(); } @@ -162,49 +164,45 @@ class _CheckoutPageState extends State { width: double.infinity, child: Consumer( builder: (context, viewModel, _) { - final isLoading = viewModel.createOrderStatus.type == StatusType.loading; - - final hasDeliveryChoice = (viewModel.deliveryChoice ?? '').isNotEmpty; - final isPickup = viewModel.deliveryChoice == 'pickup'; + final isLoading = viewModel.isCheckoutProcessing; + final canRetryOrderCreation = + viewModel.canRetryOrderCreation && !isLoading && !viewModel.hasCompletedCheckout; + final hasSelectedPaymentMethod = viewModel.selectedPaymentType != null; - final hasValidDelivery = - hasDeliveryChoice && - (isPickup - ? viewModel.selectedInpost != null - : viewModel.isShippingAddressConfirmed && viewModel.hasShippingAddress); + final canSubmitCheckout = + hasSelectedPaymentMethod && + viewModel.hasValidDeliveryDetails && + viewModel.total > 0 && + !isLoading && + !viewModel.hasCompletedCheckout; - final canAttemptPayment = hasValidDelivery && basket.total > 0 && !isLoading; + final canAttemptPayment = canRetryOrderCreation || canSubmitCheckout; return FilledButton( onPressed: canAttemptPayment ? () async { - // ✅ require payment method - if (!viewModel.hasPaymentMethod) { + if (canRetryOrderCreation) { + setState(() => _errorMessage = ''); + await viewModel.retryOrderCreation(); + return; + } + + if (!viewModel.hasValidDeliveryDetails) { setState(() { - _errorMessage = AppStrings.checkoutPaymentMethodRequired; + _errorMessage = AppStrings.checkoutDeliveryAddressRequired; }); + return; + } - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => const SelectPaymentTypeBottomSheet(), - ); - - if (!viewModel.hasPaymentMethod) return; + if (!hasSelectedPaymentMethod) { + setState(() { + _errorMessage = AppStrings.checkoutPaymentMethodRequired; + }); + return; } setState(() => _errorMessage = ''); - - await viewModel.storeOrderInFirestore(); - - final paid = await viewModel.payWithPaymentSheet( - amount: basket.total, - ); - - if (paid) { - await viewModel.createOrder(); - } + await viewModel.submitCheckout(); } : null, child: isLoading @@ -216,7 +214,13 @@ class _CheckoutPageState extends State { color: Colors.white, ), ) - : Text(AppStrings.checkoutPay), + : Text( + viewModel.checkoutFlowState == CheckoutFlowState.shipmentPending + ? AppStrings.checkoutDeliveryPending + : canRetryOrderCreation + ? AppStrings.checkoutRetryOrder + : AppStrings.checkoutPay, + ), ); }, ), @@ -224,12 +228,6 @@ class _CheckoutPageState extends State { ); } - Future gotoCheckoutComplete() async { - await Future.delayed(const Duration(seconds: 1)); - if (!mounted) return; - Navigator.pushReplacementNamed(context, AppRoutes.checkoutComplete); - } - @override void dispose() { _vm?.removeListener(_handleOrderStatus); // ✅ safe diff --git a/lib/features/checkout/checkout_repository.dart b/lib/features/checkout/checkout_repository.dart index 769962f0..914e56b0 100644 --- a/lib/features/checkout/checkout_repository.dart +++ b/lib/features/checkout/checkout_repository.dart @@ -41,8 +41,7 @@ final class CheckoutRepository implements ICheckoutRepository { return Result.success(jsonList); } else { return Result.failure( - result.error ?? - 'Pickup points currently unavailable, please try again later', + result.error ?? 'Pickup points currently unavailable, please try again later', ); } } catch (e) { @@ -86,7 +85,6 @@ final class CheckoutRepository implements ICheckoutRepository { 'updated_at': DateTime.now().toIso8601String(), }; - final result = await _firestoreService.saveDocument( FirestoreConstants.orders, orderId, @@ -143,11 +141,7 @@ final class CheckoutRepository implements ICheckoutRepository { data: order, ); if (result.isSuccess && result.value != null) { - final data = result.value; - - final jsonList = data['data'] ?? data; - - return Result.success(jsonList); + return Result.success(result.value); } else { return Result.failure( result.error ?? 'Error creating payment, please try again later', diff --git a/lib/features/checkout/checkout_view_model.dart b/lib/features/checkout/checkout_view_model.dart index dedf886a..50f0f865 100644 --- a/lib/features/checkout/checkout_view_model.dart +++ b/lib/features/checkout/checkout_view_model.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:logging/logging.dart'; import 'package:cherry_mvp/core/config/config.dart'; import 'package:cherry_mvp/core/models/inpost_model.dart'; @@ -12,10 +13,34 @@ import 'package:cherry_mvp/features/checkout/constants/address_constants.dart'; import 'package:cherry_mvp/features/checkout/models/payment_intent.dart'; import 'package:cherry_mvp/features/checkout/payment_type.dart'; import 'package:cherry_mvp/features/checkout/widgets/shipping_address_widget.dart'; -import 'package:cherry_mvp/features/checkout/constants/address_constants.dart'; + +enum CheckoutFlowState { + uninitialized, + paymentProcessing, + paymentCompleted, + orderCreationProcessing, + orderCreated, + shipmentCreated, + shipmentPending, + paymentFailure, + orderCreationFailure, + shipmentFailure, +} /// ViewModel for managing checkout state including basket items, shipping address, and payment method class CheckoutViewModel extends ChangeNotifier { + static const int _mvpDefaultShippingWeightGrams = 500; + static const String _gbCountryCode = 'GB'; + static const String _pickupDeliveryChoice = 'pickup'; + static const String _pickupDeliveryType = 'pickup_point'; + static const String _homeDeliveryType = 'home'; + static const String _pickupShippingOptionId = 'mvp-pickup-point-delivery'; + static const String _homeShippingOptionId = 'mvp-home-delivery'; + static const String _pickupShippingOptionName = 'MVP pick-up point delivery'; + static const String _homeShippingOptionName = 'MVP home delivery'; + static const String _pickupShippingCarrier = 'inpost'; + static const String _homeShippingCarrier = 'evri'; + final ICheckoutRepository checkoutRepository; final NavigationProvider navigator; final _log = Logger('CheckoutViewModel'); @@ -29,6 +54,25 @@ class CheckoutViewModel extends ChangeNotifier { Status _createOrderStatus = Status.uninitialized; Status get createOrderStatus => _createOrderStatus; + CheckoutFlowState _checkoutFlowState = CheckoutFlowState.uninitialized; + CheckoutFlowState get checkoutFlowState => _checkoutFlowState; + + String? _lastPaymentIntentId; + String? get lastPaymentIntentId => _lastPaymentIntentId; + + bool get isCheckoutProcessing => + _checkoutFlowState == CheckoutFlowState.paymentProcessing || + _checkoutFlowState == CheckoutFlowState.paymentCompleted || + _checkoutFlowState == CheckoutFlowState.orderCreationProcessing; + + bool get hasCompletedCheckout => + _checkoutFlowState == CheckoutFlowState.shipmentCreated || + _checkoutFlowState == CheckoutFlowState.shipmentPending; + + bool get canRetryOrderCreation => + _checkoutFlowState == CheckoutFlowState.orderCreationFailure && + (_lastPaymentIntentId?.trim().isNotEmpty ?? false); + final List _basketItems = []; final List _nearestInpost = []; @@ -42,17 +86,17 @@ class CheckoutViewModel extends ChangeNotifier { String? deliveryChoice; - setDeliveryChoice(String val) { + void setDeliveryChoice(String val) { deliveryChoice = val; notifyListeners(); } - setShowLocker(bool val) { + void setShowLocker(bool val) { showLocker = val; notifyListeners(); } - setSelectedInpost(var data) { + void setSelectedInpost(var data) { selectedInpost = data; notifyListeners(); } @@ -93,6 +137,12 @@ class CheckoutViewModel extends ChangeNotifier { /// Whether the order is ready for checkout (has both address and payment method) bool get canCheckout => hasShippingAddress && hasPaymentMethod; + bool get hasValidDeliveryDetails { + if ((deliveryChoice ?? '').isEmpty) return false; + if (_isPickupDelivery) return selectedInpost != null; + return isShippingAddressConfirmed && hasShippingAddress && validateShippingAddress(); + } + void setAddressConfirmed(bool value) { isShippingAddressConfirmed = value; notifyListeners(); @@ -187,6 +237,8 @@ class CheckoutViewModel extends ChangeNotifier { _basketItems.clear(); deliveryChoice = null; _createOrderStatus = Status.uninitialized; + _checkoutFlowState = CheckoutFlowState.uninitialized; + _lastPaymentIntentId = null; notifyListeners(); } @@ -222,58 +274,8 @@ class CheckoutViewModel extends ChangeNotifier { if (!canCheckout) return false; if (!validateShippingAddress()) return false; - try { - // Prepare order data for API call - final Map orderData = { - 'items': basketItems - .map( - (item) => { - 'id': item.id, - 'name': item.name, - 'price': item.price, - // Add other product fields as needed - }, - ) - .toList(), - 'shipping_address': { - 'formatted_address': formattedShippingAddress, - AddressConstants.streetKey: shippingAddressComponents[AddressConstants.streetKey], - AddressConstants.cityKey: shippingAddressComponents[AddressConstants.cityKey], - AddressConstants.stateKey: shippingAddressComponents[AddressConstants.stateKey], - 'postal_code': shippingAddressComponents[AddressConstants.postalCodeKey], - AddressConstants.countryKey: shippingAddressComponents[AddressConstants.countryKey], - 'latitude': _shippingAddress?.latitude, - 'longitude': _shippingAddress?.longitude, - }, - 'totals': { - 'item_total': itemTotal, - 'security_fee': securityFee, - 'postage': postage, - 'total': total, - }, - }; - - // Validate order data structure - if (orderData['items'] == null || (orderData['items'] as List).isEmpty) { - return false; - } - - // Call the repository to create the order via API - final result = await checkoutRepository.createOrder(orderData); - - if (result.isSuccess) { - _log.info('Checkout processed successfully'); - return true; - } else { - _log.warning('Checkout failed: ${result.error}'); - return false; - } - } catch (e) { - // Log error for debugging purposes - _log.severe('Checkout error: $e'); - debugPrint('${AddressConstants.checkoutError}: $e'); - return false; - } + await submitCheckout(); + return hasCompletedCheckout; } Future onConfirmLocation(String postalCode) async { @@ -410,20 +412,42 @@ class CheckoutViewModel extends ChangeNotifier { } } - Future payWithPaymentSheet({required double amount}) async { - if (selectedPaymentType == null) { + Future submitCheckout() async { + final paymentIntentId = await payWithPaymentSheet(amount: total); + if (paymentIntentId == null) return; + + await createOrder(paymentIntentId: paymentIntentId); + } + + Future retryOrderCreation() async { + final paymentIntentId = _lastPaymentIntentId?.trim(); + if (paymentIntentId == null || paymentIntentId.isEmpty) { + _checkoutFlowState = CheckoutFlowState.orderCreationFailure; + _createOrderStatus = Status.failure( + AppStrings.checkoutPaymentDetailsUnavailable, + ); + notifyListeners(); + return; + } + + await createOrder(paymentIntentId: paymentIntentId); + } + + Future payWithPaymentSheet({required double amount}) async { + if (!hasPaymentMethod || selectedPaymentType == null) { + _checkoutFlowState = CheckoutFlowState.paymentFailure; _createOrderStatus = Status.failure( AppStrings.checkoutPaymentMethodRequired, ); notifyListeners(); - return false; + return null; } + _checkoutFlowState = CheckoutFlowState.paymentProcessing; _createOrderStatus = Status.loading; notifyListeners(); try { - // To create a PaymentIntent and return the client_secret final response = await checkoutRepository.createPaymentIntent(amount); if (response.isSuccess && response.value != null) { @@ -443,77 +467,283 @@ class CheckoutViewModel extends ChangeNotifier { // Present the native PaymentSheet (it will show ApplePay/GooglePay if available) await Stripe.instance.presentPaymentSheet(); - return true; + + final paymentIntentId = _resolvePaymentIntentId(paymentResponse); + if (paymentIntentId == null) { + _checkoutFlowState = CheckoutFlowState.paymentFailure; + _createOrderStatus = Status.failure( + AppStrings.checkoutPaymentDetailsUnavailable, + ); + _log.severe('Payment details unavailable after PaymentSheet completion'); + notifyListeners(); + return null; + } + + _lastPaymentIntentId = paymentIntentId; + _checkoutFlowState = CheckoutFlowState.paymentCompleted; + notifyListeners(); + return paymentIntentId; } else { - _createOrderStatus = Status.failure(response.error.toString()); - _log.severe('Create Payment intent Error :: ${response.error}'); + _checkoutFlowState = CheckoutFlowState.paymentFailure; + _createOrderStatus = Status.failure( + AppStrings.checkoutPaymentDetailsUnavailable, + ); + _log.severe('Unexpected payment failure'); notifyListeners(); - return false; + return null; } } on StripeException catch (e) { + final wasCancelled = e.error.code == FailureCode.Canceled; + _checkoutFlowState = CheckoutFlowState.paymentFailure; _createOrderStatus = Status.failure( - e.error.localizedMessage ?? e.toString(), - ); - _log.severe( - 'Stripe Payment Error :: ${e.error.localizedMessage ?? e.toString()}', + wasCancelled ? AppStrings.checkoutPaymentCancelled : AppStrings.checkoutPaymentFailed, ); + _log.severe('Stripe payment failed during PaymentSheet presentation'); notifyListeners(); - return false; + return null; } catch (e) { - _createOrderStatus = Status.failure(e.toString()); - _log.severe('Error making payment::: $e'); + _checkoutFlowState = CheckoutFlowState.paymentFailure; + _createOrderStatus = Status.failure(AppStrings.checkoutPaymentFailed); + _log.severe('Unexpected payment failure'); notifyListeners(); - return false; + return null; } } - Future createOrder() async { - _createOrderStatus = Status.loading; - notifyListeners(); + Future createOrder({required String paymentIntentId}) async { + final trimmedPaymentIntentId = paymentIntentId.trim(); - if (basketItems.isEmpty) { - _createOrderStatus = Status.failure('Your basket is empty'); + if (trimmedPaymentIntentId.isEmpty) { + _checkoutFlowState = CheckoutFlowState.orderCreationFailure; + _createOrderStatus = Status.failure( + AppStrings.checkoutPaymentDetailsUnavailable, + ); notifyListeners(); return; } - final Map address = deliveryChoice == "pickup" - ? { - "line1": selectedInpost?.address ?? '', - "city": "London", - "state": "London", - "postal_code": selectedInpost?.postcode ?? '', - "country": AppStrings.unitedKingdomText, - } - : { - 'line1': _shippingAddress?.line1 ?? '', - "city": shippingAddressComponents[AddressConstants.cityKey] ?? "", - "state": shippingAddressComponents[AddressConstants.stateKey] ?? "", - 'postal_code': shippingAddressComponents[AddressConstants.postalCodeKey] ?? "", - "country": shippingAddressComponents[AddressConstants.countryKey] ?? AppStrings.unitedKingdomText, - }; + final validationMessage = _orderCreationValidationMessage(); + if (validationMessage != null) { + _checkoutFlowState = CheckoutFlowState.orderCreationFailure; + _createOrderStatus = Status.failure(validationMessage); + notifyListeners(); + return; + } - final Map orderData = { - "amount": _toMinorUnits(total), - "productId": basketItems[0].id, - "productName": basketItems[0].name, - "shipping": {"address": address, "name": 'John Doe'}, - }; + _checkoutFlowState = CheckoutFlowState.orderCreationProcessing; + _createOrderStatus = Status.loading; + notifyListeners(); + + final orderData = _buildOrderPayload(trimmedPaymentIntentId); try { final result = await checkoutRepository.createOrder(orderData); if (result.isSuccess) { - _createOrderStatus = Status.success; + _checkoutFlowState = _resolveShipmentFlowState(result.value); + _createOrderStatus = _checkoutFlowState == CheckoutFlowState.shipmentFailure + ? Status.failure(AppStrings.checkoutShipmentFailed) + : Status.success; } else { - _createOrderStatus = Status.failure(result.error ?? ""); - _log.warning('Create order failed! ${result.error}'); + _checkoutFlowState = CheckoutFlowState.orderCreationFailure; + _createOrderStatus = Status.failure( + AppStrings.checkoutOrderCreationFailed, + ); + _log.warning('Order creation failed after payment'); } } catch (e) { - _createOrderStatus = Status.failure(e.toString()); - _log.severe('Create order failed! ${e.toString()}'); + _checkoutFlowState = CheckoutFlowState.orderCreationFailure; + _createOrderStatus = Status.failure( + AppStrings.checkoutOrderCreationFailed, + ); + _log.severe('Unexpected order creation failure'); } notifyListeners(); } + String? _orderCreationValidationMessage() { + if (basketItems.isEmpty) return 'Your basket is empty'; + if ((deliveryChoice ?? '').isEmpty) { + return AppStrings.checkoutDeliveryOptionRequired; + } + if (_isPickupDelivery) { + return selectedInpost == null ? AppStrings.checkoutPickupLockerRequired : null; + } + if (!isShippingAddressConfirmed || !hasShippingAddress || !validateShippingAddress()) { + return AppStrings.checkoutDeliveryAddressRequired; + } + return null; + } + + bool get _isPickupDelivery => deliveryChoice == _pickupDeliveryChoice; + + Map _buildOrderPayload(String paymentIntentId) { + final product = basketItems.first; + final isPickup = _isPickupDelivery; + + // TODO: Replace these MVP fallback values with the selected Sendcloud + // shipping option once option selection is wired into checkout. + return { + 'amount': _toMinorUnits(total), + 'paymentIntentId': paymentIntentId, + 'deliveryType': isPickup ? _pickupDeliveryType : _homeDeliveryType, + 'shippingOptionId': isPickup ? _pickupShippingOptionId : _homeShippingOptionId, + 'shippingWeight': _mvpDefaultShippingWeightGrams, + 'shipping': { + 'address': isPickup ? _buildPickupAddress() : _buildHomeAddress(), + 'name': _resolveShippingName(), + }, + 'productId': product.id, + 'productName': product.name, + 'shippingOptionName': isPickup ? _pickupShippingOptionName : _homeShippingOptionName, + 'shippingOptionPrice': isPickup ? 0 : _toMinorUnits(postage), + 'shippingCarrier': isPickup ? _pickupShippingCarrier : _homeShippingCarrier, + if (isPickup) 'pickupPoint': _buildPickupPointPayload(), + }; + } + + Map _buildHomeAddress() { + return { + 'line1': shippingAddressComponents[AddressConstants.streetKey] ?? '', + 'city': shippingAddressComponents[AddressConstants.cityKey] ?? '', + 'postal_code': shippingAddressComponents[AddressConstants.postalCodeKey] ?? '', + 'country': _gbCountryCode, + }; + } + + Map _buildPickupAddress() { + final pickup = selectedInpost; + return { + 'line1': pickup?.address ?? '', + 'city': _resolvePickupCity(), + 'postal_code': pickup?.postcode ?? '', + 'country': _gbCountryCode, + }; + } + + Map _buildPickupPointPayload() { + final pickup = selectedInpost; + return { + 'id': pickup?.id ?? '', + 'name': pickup?.name ?? '', + 'addressLine1': pickup?.address ?? '', + 'city': _resolvePickupCity(), + 'postalCode': pickup?.postcode ?? '', + 'country': _gbCountryCode, + 'carrier': _pickupShippingCarrier, + }; + } + + String _resolvePickupCity() { + final pickup = selectedInpost; + if (pickup == null) return ''; + + // TODO: Replace this best-effort parsing with structured Sendcloud pickup + // point address fields when they are available in checkout. + final postcode = pickup.postcode.trim(); + var addressWithoutPostcode = pickup.address.trim(); + if (postcode.isNotEmpty) { + addressWithoutPostcode = addressWithoutPostcode + .replaceAll(RegExp(RegExp.escape(postcode), caseSensitive: false), '') + .trim(); + } + + final addressParts = addressWithoutPostcode + .split(',') + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toList(); + if (addressParts.length < 2) return ''; + + return addressParts.last; + } + + String _resolveShippingName() { + try { + final user = FirebaseAuth.instance.currentUser; + final displayName = user?.displayName?.trim(); + if (displayName != null && displayName.isNotEmpty) return displayName; + + final email = user?.email?.trim(); + if (email != null && email.isNotEmpty) { + final emailPrefix = email.split('@').first.trim(); + if (emailPrefix.isNotEmpty) return emailPrefix; + } + } catch (_) { + // Firebase may be unavailable in tests. The fallback remains safe. + } + + return 'cherry customer'; + } + + String? _resolvePaymentIntentId(PaymentIntentResponse paymentResponse) { + final explicitPaymentIntentId = paymentResponse.paymentIntentId?.trim(); + if (explicitPaymentIntentId != null && explicitPaymentIntentId.isNotEmpty) { + return explicitPaymentIntentId; + } + + return _paymentIntentIdFromClientSecret(paymentResponse.paymentIntent); + } + + String? _paymentIntentIdFromClientSecret(String clientSecret) { + final trimmed = clientSecret.trim(); + const secretDelimiter = '_secret_'; + final secretIndex = trimmed.indexOf(secretDelimiter); + if (secretIndex <= 0) return null; + + // TODO: Ask the backend to return paymentIntentId explicitly so the + // frontend does not need to derive it from the Stripe client secret. + return trimmed.substring(0, secretIndex); + } + + CheckoutFlowState _resolveShipmentFlowState(dynamic payload) { + final shipmentStatus = _shipmentStatusFromResponse(payload)?.toLowerCase(); + switch (shipmentStatus) { + case 'pending': + return CheckoutFlowState.shipmentPending; + case 'failed': + case 'failure': + case 'error': + return CheckoutFlowState.shipmentFailure; + default: + return CheckoutFlowState.shipmentCreated; + } + } + + String? _shipmentStatusFromResponse(dynamic payload) { + final map = _asStringKeyedMap(payload); + if (map == null) return null; + + return _firstStringValue([ + map['shipmentStatus'], + map['shipment_status'], + _nestedMapValue(map['shipment'], 'status'), + _nestedMapValue(map['data'], 'shipmentStatus'), + _nestedMapValue(map['data'], 'shipment_status'), + map['status'], + ]); + } + + dynamic _nestedMapValue(dynamic value, String key) { + return _asStringKeyedMap(value)?[key]; + } + + String? _firstStringValue(List values) { + for (final value in values) { + final stringValue = value?.toString().trim(); + if (stringValue != null && stringValue.isNotEmpty) { + return stringValue; + } + } + return null; + } + + Map? _asStringKeyedMap(dynamic value) { + if (value is Map) return value; + if (value is Map) { + return value.map((key, value) => MapEntry(key.toString(), value)); + } + return null; + } + int _toMinorUnits(double amount) { return (amount * 100).round(); } diff --git a/lib/features/checkout/models/payment_intent.dart b/lib/features/checkout/models/payment_intent.dart index 2a383037..97cb8519 100644 --- a/lib/features/checkout/models/payment_intent.dart +++ b/lib/features/checkout/models/payment_intent.dart @@ -14,11 +14,11 @@ class PaymentIntentResponse { required this.data, }); - factory PaymentIntentResponse.fromJson(Map json) => - _$PaymentIntentResponseFromJson(json); + factory PaymentIntentResponse.fromJson(Map json) => _$PaymentIntentResponseFromJson(json); Map toJson() => _$PaymentIntentResponseToJson(this); + String? get paymentIntentId => data.paymentIntentId; String get paymentIntent => data.paymentIntent; String get ephemeralKey => data.ephemeralKey; String get customer => data.customer; @@ -27,20 +27,21 @@ class PaymentIntentResponse { @JsonSerializable() class PaymentIntentData { + final String? paymentIntentId; final String paymentIntent; final String ephemeralKey; final String customer; final String publishableKey; PaymentIntentData({ + this.paymentIntentId, required this.paymentIntent, required this.ephemeralKey, required this.customer, required this.publishableKey, }); - factory PaymentIntentData.fromJson(Map json) => - _$PaymentIntentDataFromJson(json); + factory PaymentIntentData.fromJson(Map json) => _$PaymentIntentDataFromJson(json); Map toJson() => _$PaymentIntentDataToJson(this); } diff --git a/lib/features/checkout/models/payment_intent.g.dart b/lib/features/checkout/models/payment_intent.g.dart index 851b81fd..2beffef3 100644 --- a/lib/features/checkout/models/payment_intent.g.dart +++ b/lib/features/checkout/models/payment_intent.g.dart @@ -22,18 +22,18 @@ Map _$PaymentIntentResponseToJson( 'data': instance.data, }; -PaymentIntentData _$PaymentIntentDataFromJson(Map json) => - PaymentIntentData( - paymentIntent: json['paymentIntent'] as String, - ephemeralKey: json['ephemeralKey'] as String, - customer: json['customer'] as String, - publishableKey: json['publishableKey'] as String, - ); +PaymentIntentData _$PaymentIntentDataFromJson(Map json) => PaymentIntentData( + paymentIntentId: json['paymentIntentId'] as String?, + paymentIntent: json['paymentIntent'] as String, + ephemeralKey: json['ephemeralKey'] as String, + customer: json['customer'] as String, + publishableKey: json['publishableKey'] as String, +); -Map _$PaymentIntentDataToJson(PaymentIntentData instance) => - { - 'paymentIntent': instance.paymentIntent, - 'ephemeralKey': instance.ephemeralKey, - 'customer': instance.customer, - 'publishableKey': instance.publishableKey, - }; +Map _$PaymentIntentDataToJson(PaymentIntentData instance) => { + 'paymentIntentId': instance.paymentIntentId, + 'paymentIntent': instance.paymentIntent, + 'ephemeralKey': instance.ephemeralKey, + 'customer': instance.customer, + 'publishableKey': instance.publishableKey, +}; diff --git a/lib/features/donation/widgets/donation_dropdown_field.dart b/lib/features/donation/widgets/donation_dropdown_field.dart index 3bf13ae6..7cb50c1d 100644 --- a/lib/features/donation/widgets/donation_dropdown_field.dart +++ b/lib/features/donation/widgets/donation_dropdown_field.dart @@ -73,7 +73,7 @@ class DonationDropdownFieldState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: DropdownButtonFormField( - value: selectedDropdownItem, + initialValue: selectedDropdownItem, items: widget.dropdownList.map((item) { return DropdownMenuItem( value: item, diff --git a/lib/features/donation/widgets/photo_tips_bar.dart b/lib/features/donation/widgets/photo_tips_bar.dart index a5cf9458..4d01061f 100644 --- a/lib/features/donation/widgets/photo_tips_bar.dart +++ b/lib/features/donation/widgets/photo_tips_bar.dart @@ -1,4 +1,3 @@ -import 'package:cherry_mvp/core/config/app_colors.dart'; import 'package:cherry_mvp/core/config/app_strings.dart'; import 'package:flutter/material.dart'; import 'photo_tips_dialog.dart'; // import the new dialog widget diff --git a/lib/features/password_flow/widgets/hidden_password_field.dart b/lib/features/password_flow/widgets/hidden_password_field.dart index 37abbae2..5ef59685 100644 --- a/lib/features/password_flow/widgets/hidden_password_field.dart +++ b/lib/features/password_flow/widgets/hidden_password_field.dart @@ -1,4 +1,3 @@ -import 'package:cherry_mvp/core/config/app_strings.dart'; import 'package:flutter/material.dart'; class HiddenPasswordField extends StatefulWidget { diff --git a/lib/features/products/product_page.dart b/lib/features/products/product_page.dart index 71e7bd75..a9a15fa7 100644 --- a/lib/features/products/product_page.dart +++ b/lib/features/products/product_page.dart @@ -88,7 +88,7 @@ class ProductPage extends StatelessWidget { : AppStrings.productPageDonorDiscountInactive; final donorDiscountDetail = isDonorDiscountActive ? AppStrings.productPageBuy2Get1HalfPrice - : AppStrings.productPageDonorDiscountInactiveDetail; + : ''; return Column( children: [ diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index 4fb44811..5b67a7c1 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -11,7 +11,7 @@ import 'package:cherry_mvp/core/router/router.dart'; import '../auth/auth_view_model.dart'; class ProfilePage extends StatefulWidget { - ProfilePage({super.key}); + const ProfilePage({super.key}); @override State createState() => _ProfilePageState(); diff --git a/lib/main.dart b/lib/main.dart index 588e568f..c794a54b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); /// Load environment variables - await dotenv.load(); + await dotenv.load(fileName: "assets/.env"); await Firebase.initializeApp(); await _configureFirebaseEmulators(); diff --git a/test/auth_ui_test.mocks.dart b/test/auth_ui_test.mocks.dart new file mode 100644 index 00000000..0866186b --- /dev/null +++ b/test/auth_ui_test.mocks.dart @@ -0,0 +1,219 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in cherry_mvp/test/auth_ui_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:cherry_mvp/core/models/model.dart' as _i6; +import 'package:cherry_mvp/core/router/nav_provider.dart' as _i8; +import 'package:cherry_mvp/core/utils/utils.dart' as _i2; +import 'package:cherry_mvp/features/login/login_model.dart' as _i7; +import 'package:cherry_mvp/features/login/login_repository.dart' as _i4; +import 'package:flutter/material.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeResult_0 extends _i1.SmartFake implements _i2.Result { + _FakeResult_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeGlobalKey_1> + extends _i1.SmartFake + implements _i3.GlobalKey { + _FakeGlobalKey_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [LoginRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLoginRepository extends _i1.Mock implements _i4.LoginRepository { + @override + _i5.Future<_i2.Result<_i6.UserCredentials?>> login( + _i7.LoginRequest? request, + ) => + (super.noSuchMethod( + Invocation.method(#login, [request]), + returnValue: _i5.Future<_i2.Result<_i6.UserCredentials?>>.value( + _FakeResult_0<_i6.UserCredentials?>( + this, + Invocation.method(#login, [request]), + ), + ), + returnValueForMissingStub: + _i5.Future<_i2.Result<_i6.UserCredentials?>>.value( + _FakeResult_0<_i6.UserCredentials?>( + this, + Invocation.method(#login, [request]), + ), + ), + ) + as _i5.Future<_i2.Result<_i6.UserCredentials?>>); + + @override + _i5.Future<_i2.Result<_i6.UserCredentials?>> signInWithGoogle() => + (super.noSuchMethod( + Invocation.method(#signInWithGoogle, []), + returnValue: _i5.Future<_i2.Result<_i6.UserCredentials?>>.value( + _FakeResult_0<_i6.UserCredentials?>( + this, + Invocation.method(#signInWithGoogle, []), + ), + ), + returnValueForMissingStub: + _i5.Future<_i2.Result<_i6.UserCredentials?>>.value( + _FakeResult_0<_i6.UserCredentials?>( + this, + Invocation.method(#signInWithGoogle, []), + ), + ), + ) + as _i5.Future<_i2.Result<_i6.UserCredentials?>>); + + @override + _i5.Future<_i2.Result<_i6.UserCredentials?>> signInWithApple() => + (super.noSuchMethod( + Invocation.method(#signInWithApple, []), + returnValue: _i5.Future<_i2.Result<_i6.UserCredentials?>>.value( + _FakeResult_0<_i6.UserCredentials?>( + this, + Invocation.method(#signInWithApple, []), + ), + ), + returnValueForMissingStub: + _i5.Future<_i2.Result<_i6.UserCredentials?>>.value( + _FakeResult_0<_i6.UserCredentials?>( + this, + Invocation.method(#signInWithApple, []), + ), + ), + ) + as _i5.Future<_i2.Result<_i6.UserCredentials?>>); + + @override + _i5.Future<_i2.Result> fetchUserFromFirestore(String? uid) => + (super.noSuchMethod( + Invocation.method(#fetchUserFromFirestore, [uid]), + returnValue: _i5.Future<_i2.Result>.value( + _FakeResult_0( + this, + Invocation.method(#fetchUserFromFirestore, [uid]), + ), + ), + returnValueForMissingStub: _i5.Future<_i2.Result>.value( + _FakeResult_0( + this, + Invocation.method(#fetchUserFromFirestore, [uid]), + ), + ), + ) + as _i5.Future<_i2.Result>); + + @override + _i5.Future<_i2.Result> logout() => + (super.noSuchMethod( + Invocation.method(#logout, []), + returnValue: _i5.Future<_i2.Result>.value( + _FakeResult_0(this, Invocation.method(#logout, [])), + ), + returnValueForMissingStub: _i5.Future<_i2.Result>.value( + _FakeResult_0(this, Invocation.method(#logout, [])), + ), + ) + as _i5.Future<_i2.Result>); +} + +/// A class which mocks [NavigationProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockNavigationProvider extends _i1.Mock + implements _i8.NavigationProvider { + @override + _i3.GlobalKey<_i3.NavigatorState> get navigatorKey => + (super.noSuchMethod( + Invocation.getter(#navigatorKey), + returnValue: _FakeGlobalKey_1<_i3.NavigatorState>( + this, + Invocation.getter(#navigatorKey), + ), + returnValueForMissingStub: _FakeGlobalKey_1<_i3.NavigatorState>( + this, + Invocation.getter(#navigatorKey), + ), + ) + as _i3.GlobalKey<_i3.NavigatorState>); + + @override + _i5.Future navigateTo(String? routeName, {Object? arguments}) => + (super.noSuchMethod( + Invocation.method( + #navigateTo, + [routeName], + {#arguments: arguments}, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future replaceWith(String? routeName, {Object? arguments}) => + (super.noSuchMethod( + Invocation.method( + #replaceWith, + [routeName], + {#arguments: arguments}, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future navigateToAndRemoveUntil( + String? routeName, + _i3.RoutePredicate? predicate, { + Object? arguments, + }) => + (super.noSuchMethod( + Invocation.method( + #navigateToAndRemoveUntil, + [routeName, predicate], + {#arguments: arguments}, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + void goBack([Object? arguments]) => super.noSuchMethod( + Invocation.method(#goBack, [arguments]), + returnValueForMissingStub: null, + ); + + @override + _i5.Future showPurchaseSecurity() => + (super.noSuchMethod( + Invocation.method(#showPurchaseSecurity, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); +} diff --git a/test/checkout_view_model_test.dart b/test/checkout_view_model_test.dart index 6744d1b7..20ac5927 100644 --- a/test/checkout_view_model_test.dart +++ b/test/checkout_view_model_test.dart @@ -1,9 +1,12 @@ import 'package:cherry_mvp/core/router/nav_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; +import 'package:cherry_mvp/core/models/inpost_model.dart'; +import 'package:cherry_mvp/core/models/product.dart'; import 'package:cherry_mvp/core/utils/result.dart'; import 'package:cherry_mvp/core/utils/status.dart'; import 'package:cherry_mvp/features/checkout/checkout_repository.dart'; +import 'package:cherry_mvp/features/checkout/models/payment_intent.dart'; import 'package:cherry_mvp/features/checkout/checkout_view_model.dart'; import 'package:cherry_mvp/features/checkout/widgets/shipping_address_widget.dart'; @@ -14,6 +17,10 @@ class FakeCheckoutRepository implements ICheckoutRepository { FakeCheckoutRepository({this.fetchNearestResult}); final Result? fetchNearestResult; + Map? createdOrder; + Result createOrderResult = Result.success({ + 'shipment': {'id': 'shipment_123'}, + }); @override Future fetchNearestInPosts(String postalCode) async { @@ -23,10 +30,72 @@ class FakeCheckoutRepository implements ICheckoutRepository { @override Future storeOrderInFirestore(Map orderData) async {} + @override + Future> createPaymentIntent(double amount) async { + return Result.failure('Payment intent creation is not used in this test.'); + } + + @override + Future createOrder(Map order) async { + createdOrder = order; + return createOrderResult; + } + + @override + Future storeLockerInFirestore(InpostModel data) async {} + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +Product _testProduct({double price = 10}) { + return Product( + id: 'product_123', + name: 'Test jumper', + description: 'A warm jumper', + quality: 'Good', + productImages: const [], + donation: 0, + price: price, + likes: 0, + number: 1, + size: 'M', + ); +} + +PlaceDetails _validUkAddress() { + return PlaceDetails( + formattedAddress: '10 Test Street, London, SW1A 1AA, United Kingdom', + addressComponents: [ + AddressComponent( + longName: '10', + shortName: '10', + types: ['street_number'], + ), + AddressComponent( + longName: 'Test Street', + shortName: 'Test Street', + types: ['route'], + ), + AddressComponent( + longName: 'London', + shortName: 'London', + types: ['locality'], + ), + AddressComponent( + longName: 'SW1A 1AA', + shortName: 'SW1A 1AA', + types: ['postal_code'], + ), + AddressComponent( + longName: 'United Kingdom', + shortName: 'GB', + types: ['country'], + ), + ], + ); +} + void main() { group('CheckoutViewModel', () { late CheckoutViewModel viewModel; @@ -176,6 +245,114 @@ void main() { expect(viewModel.canCheckout, false); }); + test('creates home delivery order payload after payment succeeds', () async { + final fakeRepository = FakeCheckoutRepository(); + viewModel = CheckoutViewModel( + checkoutRepository: fakeRepository, + navigator: mockNavigator, + ); + + viewModel.addItem(_testProduct()); + viewModel.setDeliveryChoice('home'); + viewModel.setShippingAddress(_validUkAddress()); + viewModel.setAddressConfirmed(true); + + await viewModel.createOrder(paymentIntentId: 'pi_test_123'); + + final order = fakeRepository.createdOrder; + expect(order, isNotNull); + expect(order!['amount'], 1399); + expect(order['paymentIntentId'], 'pi_test_123'); + expect(order['deliveryType'], 'home'); + expect(order['shippingOptionId'], 'mvp-home-delivery'); + expect(order['shippingWeight'], 500); + expect(order['productId'], 'product_123'); + expect(order['productName'], 'Test jumper'); + expect(order['shippingOptionName'], 'MVP home delivery'); + expect(order['shippingOptionPrice'], 299); + expect(order['shippingCarrier'], 'evri'); + + final shipping = order['shipping'] as Map; + final address = shipping['address'] as Map; + expect(address['line1'], '10 Test Street'); + expect(address['city'], 'London'); + expect(address['postal_code'], 'SW1A 1AA'); + expect(address['country'], 'GB'); + expect(shipping['name'], isNot('John Doe')); + expect((shipping['name'] as String).trim(), isNotEmpty); + }); + + test('creates pick-up point order payload after payment succeeds', () async { + final fakeRepository = FakeCheckoutRepository(); + viewModel = CheckoutViewModel( + checkoutRepository: fakeRepository, + navigator: mockNavigator, + ); + + viewModel.addItem(_testProduct()); + viewModel.setDeliveryChoice('pickup'); + viewModel.setSelectedInpost( + const InpostModel( + id: 'locker_123', + name: 'Locker One', + address: '1 Pickup Street, London', + postcode: 'SW1A 1AA', + lat: '51.501', + long: '-0.141', + ), + ); + + await viewModel.createOrder(paymentIntentId: 'pi_pickup_123'); + + final order = fakeRepository.createdOrder; + expect(order, isNotNull); + expect(order!['amount'], 1399); + expect(order['paymentIntentId'], 'pi_pickup_123'); + expect(order['deliveryType'], 'pickup_point'); + expect(order['shippingOptionId'], 'mvp-pickup-point-delivery'); + expect(order['shippingWeight'], 500); + expect(order['shippingOptionName'], 'MVP pick-up point delivery'); + expect(order['shippingOptionPrice'], 0); + expect(order['shippingCarrier'], 'inpost'); + + final shipping = order['shipping'] as Map; + final address = shipping['address'] as Map; + expect(address['line1'], '1 Pickup Street, London'); + expect(address['city'], 'London'); + expect(address['postal_code'], 'SW1A 1AA'); + expect(address['country'], 'GB'); + expect(shipping['name'], isNot('John Doe')); + + final pickupPoint = order['pickupPoint'] as Map; + expect(pickupPoint['id'], 'locker_123'); + expect(pickupPoint['name'], 'Locker One'); + expect(pickupPoint['addressLine1'], '1 Pickup Street, London'); + expect(pickupPoint['city'], 'London'); + expect(pickupPoint['postalCode'], 'SW1A 1AA'); + expect(pickupPoint['country'], 'GB'); + expect(pickupPoint['carrier'], 'inpost'); + }); + + test('pending shipment response records partial checkout success', () async { + final fakeRepository = FakeCheckoutRepository() + ..createOrderResult = Result.success({'shipmentStatus': 'pending'}); + viewModel = CheckoutViewModel( + checkoutRepository: fakeRepository, + navigator: mockNavigator, + ); + + viewModel.addItem(_testProduct()); + viewModel.setDeliveryChoice('home'); + viewModel.setShippingAddress(_validUkAddress()); + viewModel.setAddressConfirmed(true); + + await viewModel.createOrder(paymentIntentId: 'pi_pending_123'); + + expect(viewModel.checkoutFlowState, CheckoutFlowState.shipmentPending); + expect(viewModel.hasCompletedCheckout, true); + expect(viewModel.createOrderStatus.type, StatusType.success); + }); + test('should extract address components correctly', () { final testAddress = PlaceDetails( formattedAddress: 'Test Address', diff --git a/test/checkout_view_model_test.mocks.dart b/test/checkout_view_model_test.mocks.dart new file mode 100644 index 00000000..6333b086 --- /dev/null +++ b/test/checkout_view_model_test.mocks.dart @@ -0,0 +1,111 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in cherry_mvp/test/checkout_view_model_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:cherry_mvp/core/router/nav_provider.dart' as _i3; +import 'package:flutter/material.dart' as _i1; +import 'package:mockito/mockito.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeGlobalKey_0> + extends _i2.SmartFake + implements _i1.GlobalKey { + _FakeGlobalKey_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [NavigationProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockNavigationProvider extends _i2.Mock + implements _i3.NavigationProvider { + @override + _i1.GlobalKey<_i1.NavigatorState> get navigatorKey => + (super.noSuchMethod( + Invocation.getter(#navigatorKey), + returnValue: _FakeGlobalKey_0<_i1.NavigatorState>( + this, + Invocation.getter(#navigatorKey), + ), + returnValueForMissingStub: _FakeGlobalKey_0<_i1.NavigatorState>( + this, + Invocation.getter(#navigatorKey), + ), + ) + as _i1.GlobalKey<_i1.NavigatorState>); + + @override + _i4.Future navigateTo(String? routeName, {Object? arguments}) => + (super.noSuchMethod( + Invocation.method( + #navigateTo, + [routeName], + {#arguments: arguments}, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future replaceWith(String? routeName, {Object? arguments}) => + (super.noSuchMethod( + Invocation.method( + #replaceWith, + [routeName], + {#arguments: arguments}, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future navigateToAndRemoveUntil( + String? routeName, + _i1.RoutePredicate? predicate, { + Object? arguments, + }) => + (super.noSuchMethod( + Invocation.method( + #navigateToAndRemoveUntil, + [routeName, predicate], + {#arguments: arguments}, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + void goBack([Object? arguments]) => super.noSuchMethod( + Invocation.method(#goBack, [arguments]), + returnValueForMissingStub: null, + ); + + @override + _i4.Future showPurchaseSecurity() => + (super.noSuchMethod( + Invocation.method(#showPurchaseSecurity, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); +} From c53352e074b27fdbd1cb764fb405bf93b1308987 Mon Sep 17 00:00:00 2001 From: cherry Date: Sun, 17 May 2026 00:03:34 +0100 Subject: [PATCH 2/2] Address PR review feedback Apply safe review fixes for PR #413: restore root dotenv loading, keep pickup checkout validation aligned with delivery choice, prevent repeat payment attempts after shipment failure, sync the donation dropdown with parent reset state, and correct donation helper copy. Type: frontend UI/state and configuration cleanup. Backend/API contract is unchanged; the multi-item payload and unknown-shipment-status CodeRabbit suggestions were reviewed but not applied because they conflict with the documented /api/order/create contract. Testing: dart format was run on touched files before staging; git diff --check passed. flutter analyze and the focused checkout ViewModel test previously hung in this local toolchain, so they were not completed locally. --- lib/core/config/app_strings.dart | 4 ++-- lib/features/checkout/checkout_page.dart | 1 + .../checkout/checkout_view_model.dart | 4 ++-- .../widgets/donation_dropdown_field.dart | 20 +++++++++++-------- lib/main.dart | 2 +- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/core/config/app_strings.dart b/lib/core/config/app_strings.dart index 5d294a7e..0c4272ae 100644 --- a/lib/core/config/app_strings.dart +++ b/lib/core/config/app_strings.dart @@ -136,8 +136,8 @@ class AppStrings { // Donation static const donationsText = "Donate"; static const donationOptionsText = "Donation Options"; - static const giveYourBuyerText = "Give your buyer the option pick a cause they care about."; - static const easyWayText = "Its an easy way to make your listing more impactful."; + static const giveYourBuyerText = "Give your buyer the option to pick a cause they care about."; + static const easyWayText = "It's an easy way to make your listing more impactful."; static const openToOtherCharitiesText = "Open to other charities"; static const openToOffersText = "Open to offers"; static const applicableForBuyerDiscountsText = "Applicable for donor discounts"; diff --git a/lib/features/checkout/checkout_page.dart b/lib/features/checkout/checkout_page.dart index b54cf171..cf613bea 100644 --- a/lib/features/checkout/checkout_page.dart +++ b/lib/features/checkout/checkout_page.dart @@ -173,6 +173,7 @@ class _CheckoutPageState extends State { hasSelectedPaymentMethod && viewModel.hasValidDeliveryDetails && viewModel.total > 0 && + viewModel.checkoutFlowState != CheckoutFlowState.shipmentFailure && !isLoading && !viewModel.hasCompletedCheckout; diff --git a/lib/features/checkout/checkout_view_model.dart b/lib/features/checkout/checkout_view_model.dart index 1417529b..7dcfb097 100644 --- a/lib/features/checkout/checkout_view_model.dart +++ b/lib/features/checkout/checkout_view_model.dart @@ -271,8 +271,8 @@ class CheckoutViewModel extends ChangeNotifier { /// Processes the checkout order /// Returns true if successful, false if validation fails or error occurs Future processCheckout() async { - if (!canCheckout) return false; - if (!validateShippingAddress()) return false; + if (!hasPaymentMethod) return false; + if (!hasValidDeliveryDetails) return false; await submitCheckout(); return hasCompletedCheckout; diff --git a/lib/features/donation/widgets/donation_dropdown_field.dart b/lib/features/donation/widgets/donation_dropdown_field.dart index 7cb50c1d..8e59964d 100644 --- a/lib/features/donation/widgets/donation_dropdown_field.dart +++ b/lib/features/donation/widgets/donation_dropdown_field.dart @@ -29,7 +29,13 @@ class DonationDropdownFieldState extends State { selectedDropdownItem = widget.selectedValue; } - + @override + void didUpdateWidget(covariant DonationDropdownField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedValue != widget.selectedValue) { + selectedDropdownItem = widget.selectedValue; + } + } Widget charityRow(String item) { final index = widget.dropdownList.indexOf(item); @@ -38,7 +44,8 @@ class DonationDropdownFieldState extends State { : null; return SizedBox( - width: 200, /// Set the desired width to match ui + // Set the desired width to match the UI. + width: 200, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -48,8 +55,7 @@ class DonationDropdownFieldState extends State { overflow: TextOverflow.ellipsis, ), ), - if (imageUrl != null && imageUrl.isNotEmpty) - const SizedBox(width: 74), + if (imageUrl != null && imageUrl.isNotEmpty) const SizedBox(width: 74), if (imageUrl != null && imageUrl.isNotEmpty) ClipRRect( borderRadius: BorderRadius.circular(4), @@ -60,19 +66,17 @@ class DonationDropdownFieldState extends State { fit: BoxFit.cover, ), ), - - ], ), ); } - @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: DropdownButtonFormField( + key: ValueKey(selectedDropdownItem), initialValue: selectedDropdownItem, items: widget.dropdownList.map((item) { return DropdownMenuItem( @@ -104,4 +108,4 @@ class DonationDropdownFieldState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index c794a54b..588e568f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); /// Load environment variables - await dotenv.load(fileName: "assets/.env"); + await dotenv.load(); await Firebase.initializeApp(); await _configureFirebaseEmulators();