diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9391dfcf..31d60736 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 = "uk.org.cherry.app" compileSdk = flutter.compileSdkVersion - ndkVersion = localProperties.getProperty("flutter.ndkVersion") + ndkVersion = flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -37,8 +38,8 @@ android { applicationId = "uk.org.cherry.app" // 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 b8f72aa2..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"; @@ -227,6 +227,16 @@ class AppStrings { 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'; 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/checkout/checkout_page.dart b/lib/features/checkout/checkout_page.dart index 7af7a83b..cf613bea 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,46 @@ 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 && + viewModel.checkoutFlowState != CheckoutFlowState.shipmentFailure && + !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 +215,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 +229,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 4294e8f6..7dcfb097 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'; @@ -13,8 +14,33 @@ 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'; +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'); @@ -28,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 = []; @@ -92,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(); @@ -186,6 +237,8 @@ class CheckoutViewModel extends ChangeNotifier { _basketItems.clear(); deliveryChoice = null; _createOrderStatus = Status.uninitialized; + _checkoutFlowState = CheckoutFlowState.uninitialized; + _lastPaymentIntentId = null; notifyListeners(); } @@ -218,61 +271,11 @@ 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; - - 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 (!hasPaymentMethod) return false; + if (!hasValidDeliveryDetails) return false; - 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 { @@ -409,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) { @@ -442,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..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,20 +66,18 @@ class DonationDropdownFieldState extends State { fit: BoxFit.cover, ), ), - - ], ), ); } - @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: DropdownButtonFormField( - value: selectedDropdownItem, + key: ValueKey(selectedDropdownItem), + initialValue: selectedDropdownItem, items: widget.dropdownList.map((item) { return DropdownMenuItem( value: item, @@ -104,4 +108,4 @@ class DonationDropdownFieldState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/features/products/product_page.dart b/lib/features/products/product_page.dart index 8367bba2..92732cd1 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/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); +}