diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index aa693c7d8..892d3b681 100644 --- a/lib/pages/cakepay/cakepay_order_view.dart +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -4,6 +4,7 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../notifications/show_flush_bar.dart'; @@ -558,9 +559,10 @@ class _CakePayOrderViewState extends ConsumerState { children: [ Row( children: [ - Icon( - Icons.check_circle, - size: 20, + SvgPicture.asset( + Assets.svg.checkCircle, + width: 20, + height: 20, color: Theme.of( context, ).extension()!.accentColorGreen, @@ -622,9 +624,10 @@ class _CakePayOrderViewState extends ConsumerState { RoundedWhiteContainer( child: Row( children: [ - Icon( - Icons.cancel, - size: 20, + SvgPicture.asset( + Assets.svg.circleX, + width: 20, + height: 20, color: Theme.of( context, ).extension()!.textSubtitle1, diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index 0e476089f..990f43cdb 100644 --- a/lib/pages/cakepay/cakepay_orders_view.dart +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; import '../../providers/global/cakepay_orders_provider.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -135,8 +137,10 @@ class _CakePayOrdersViewState extends ConsumerState { ), ), SizedBox(width: isDesktop ? 16 : 8), - Icon( - Icons.chevron_right, + SvgPicture.asset( + Assets.svg.chevronRight, + width: 24, + height: 24, color: Theme.of( context, ).extension()!.textSubtitle1, diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 0d3d3a14a..af854f788 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -1,38 +1,31 @@ import 'dart:async'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; -import '../../route_generator.dart'; import '../../services/shopinbit/src/models/car_research.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/address_utils.dart'; -import '../../utilities/amount/amount.dart'; import '../../utilities/assets.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../widgets/background.dart'; -import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/qr.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../more_view/services_view.dart'; import 'shopinbit_order_created.dart'; -import 'shopinbit_send_from_view.dart'; +import 'shopinbit_payment_shared.dart'; import 'shopinbit_tickets_view.dart'; enum _PaymentFlowState { @@ -96,83 +89,31 @@ class _ShopInBitCarResearchPaymentViewState bool get _payNowEnabled => !_isTerminal && _flowState == _PaymentFlowState.idle; - void _confirmPayment() { + Future _confirmPayment() async { // Keep polling while the user is in the send flow. final method = _methods[_selectedMethod]; final ticker = method.toUpperCase(); - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - - String address = ""; - Amount? amount; - EthContract? tokenContract; - - if (_currentAddress.isNotEmpty) { - final parsed = AddressUtils.parsePaymentUri(_currentAddress); - - if (parsed?.address != null && parsed!.address.isNotEmpty) { - address = parsed.address; - } else { - final raw = _currentAddress; - final colonIdx = raw.indexOf(':'); - if (colonIdx != -1) { - final afterScheme = raw.substring(colonIdx + 1); - final qIdx = afterScheme.indexOf('?'); - address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; - } else { - address = raw; - } - } - - String? amountStr = parsed?.amount; - if (amountStr == null || amountStr.isEmpty) { - final uri = Uri.tryParse(_currentAddress); - if (uri != null) { - amountStr = uri.queryParameters['amount']; - } - } - // Car research flow has no concierge PaymentInfo.due fallback. - - final int fractionDigits; - if (coin != null) { - fractionDigits = coin.fractionDigits; - } else if (ticker == "USDT") { - fractionDigits = 6; - } else { - fractionDigits = 8; - } - - if (amountStr != null && amountStr.isNotEmpty) { - try { - amount = Amount.fromDecimal( - Decimal.parse(amountStr), - fractionDigits: fractionDigits, - ); - } catch (_) {} - } - } + final target = parseShopInBitPaymentTarget( + paymentUri: _currentAddress, + ticker: ticker, + coin: AppConfig.getCryptoCurrencyForTicker(ticker), + ); - if (coin != null && address.isNotEmpty) { - _navigateToSendFrom(coin: coin, amount: amount, address: address); - return; - } + final navigated = await tryNavigateToShopInBitWalletSend( + ref: ref, + context: context, + ticker: ticker, + paymentUri: _currentAddress, + address: target.address, + amount: target.amount, + model: widget.model, + // After the wallet send, pop back here so polling can continue. + routeOnSuccessName: ShopInBitCarResearchPaymentView.routeName, + ); - if (ticker == "USDT" && address.isNotEmpty) { - const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - tokenContract = ref.read(mainDBProvider).getEthContractSync(usdtAddress); - if (tokenContract != null) { - final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); - if (ethCoin != null) { - _navigateToSendFrom( - coin: ethCoin, - amount: amount, - address: address, - tokenContract: tokenContract, - ); - return; - } - } - } + if (navigated) return; + if (!mounted) return; // No compatible wallet coin found: surface an info flushbar and keep // the user on this screen so they can pay externally and then use the @@ -188,46 +129,6 @@ class _ShopInBitCarResearchPaymentViewState ); } - void _navigateToSendFrom({ - required CryptoCurrency coin, - required Amount? amount, - required String address, - EthContract? tokenContract, - }) { - if (Util.isDesktop) { - // Show send-from on top of the payment dialog, not instead of it. - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: widget.model, - shouldPopRoot: true, - tokenContract: tokenContract, - ), - ), - ); - } else { - Navigator.of(context).push( - RouteGenerator.getRoute( - shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: widget.model, - tokenContract: tokenContract, - // After wallet send, pop back to this view to continue polling. - routeOnSuccessName: ShopInBitCarResearchPaymentView.routeName, - ), - settings: const RouteSettings(name: ShopInBitSendFromView.routeName), - ), - ); - } - } - Future _checkForPayment() async { if (_flowState != _PaymentFlowState.idle) return; setState(() => _flowState = _PaymentFlowState.polling); @@ -731,26 +632,11 @@ class _ShopInBitCarResearchPaymentViewState ? _methods[_selectedMethod].toUpperCase() : ""; - bool hasWallets = false; - if (ticker == "USDT") { - const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - hasWallets = ref - .watch(pWallets) - .wallets - .any( - (w) => - w.info.coin is Ethereum && - w.info.tokenContractAddresses.contains(usdtAddress), - ); - } else { - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - if (coin != null) { - hasWallets = ref - .watch(pWallets) - .wallets - .any((e) => e.info.coin == coin); - } - } + final hasWallets = hasShopInBitWalletForTicker( + wallets: ref.watch(pWallets), + ticker: ticker, + paymentUri: _currentAddress, + ); final methodSelector = _methods.length <= 1 ? Padding( @@ -901,9 +787,9 @@ class _ShopInBitCarResearchPaymentViewState : STextStyles.itemSubtitle12(context), ), const Spacer(), - Icon( - Icons.copy, - size: 14, + CopyIcon( + width: 14, + height: 14, color: Theme.of( context, ).extension()!.accentColorBlue, @@ -941,7 +827,7 @@ class _ShopInBitCarResearchPaymentViewState enabled: _payNowEnabled, onPressed: _payNowEnabled ? (hasWallets - ? _confirmPayment + ? () => unawaited(_confirmPayment()) : () => unawaited(_checkForPayment())) : null, ), @@ -985,41 +871,9 @@ class _ShopInBitCarResearchPaymentViewState ); } - return Background( - child: PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, dynamic result) { - if (!didPop) { - _popToTickets(); - } - }, - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton(onPressed: _popToTickets), - title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), - ), - ), - ); - }, - ), - ), - ), - ), + return ShopInBitPaymentMobileScaffold( + onBack: _popToTickets, + child: content, ); } } diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart index 544a554c2..64e90d1a7 100644 --- a/lib/pages/shopinbit/shopinbit_offer_view.dart +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -13,7 +13,6 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; -import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_shipping_view.dart'; @@ -195,13 +194,7 @@ class _ShopInBitOfferViewState extends ConsumerState { bottom: 32, top: 16, ), - child: Stack( - children: [ - content, - if (_loading) - const LoadingIndicator(width: 24, height: 24), - ], - ), + child: content, ), ), ], @@ -222,21 +215,16 @@ class _ShopInBitOfferViewState extends ConsumerState { body: SafeArea( child: LayoutBuilder( builder: (context, constraints) { - return Stack( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), - ), + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, ), + child: IntrinsicHeight(child: content), ), - if (_loading) const LoadingIndicator(width: 24, height: 24), - ], + ), ); }, ), diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart new file mode 100644 index 000000000..bd6aade79 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -0,0 +1,306 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app_config.dart'; +import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../services/shopinbit/src/models/payment.dart'; +import '../../services/wallets.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/default_eth_tokens.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import 'shopinbit_send_from_view.dart'; + +final String kShopInBitUsdtContractAddress = DefaultTokens.list + .firstWhere((t) => t.symbol == "USDT") + .address; + +// Address + amount pulled out of one of the API's payment_links entries. +class ShopInBitPaymentTarget { + const ShopInBitPaymentTarget({required this.address, required this.amount}); + + final String address; + final Amount? amount; +} + +// Parses a BIP21-style payment URI (or a bare address) into a destination +// address and optional Amount. `amountFallback` covers the concierge case +// where the URI itself has no amount but the API response carries one +// (PaymentInfo.due). +ShopInBitPaymentTarget parseShopInBitPaymentTarget({ + required String paymentUri, + required String ticker, + CryptoCurrency? coin, + String? amountFallback, +}) { + String address = ""; + final parsed = AddressUtils.parsePaymentUri(paymentUri); + + if (parsed?.address != null && parsed!.address.isNotEmpty) { + address = parsed.address; + } else { + final colonIdx = paymentUri.indexOf(':'); + if (colonIdx != -1) { + final afterScheme = paymentUri.substring(colonIdx + 1); + final qIdx = afterScheme.indexOf('?'); + address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; + } else { + address = paymentUri; + } + } + + String? amountStr = parsed?.amount; + if (amountStr == null || amountStr.isEmpty) { + final uri = Uri.tryParse(paymentUri); + if (uri != null) { + amountStr = uri.queryParameters['amount']; + } + } + if (amountStr == null || amountStr.isEmpty) { + amountStr = amountFallback; + } + + final int fractionDigits; + if (coin != null) { + fractionDigits = coin.fractionDigits; + } else if (ticker == "USDT") { + fractionDigits = 6; + } else { + fractionDigits = 8; + } + + Amount? amount; + if (amountStr != null && amountStr.isNotEmpty) { + try { + amount = Amount.fromDecimal( + Decimal.parse(amountStr), + fractionDigits: fractionDigits, + ); + } catch (_) {} + } + + return ShopInBitPaymentTarget(address: address, amount: amount); +} + +// USDT exists on multiple chains (ERC-20, TRC-20, BEP-20, ...) and the +// ShopInBit API just keys the payment link as "USDT". Only treat it as +// ETH-USDT when the URI scheme is `ethereum:` or the address looks like a +// bare Ethereum hex address. Anything else (Tron, etc.) we don't support +// in-app and the user has to pay externally. +final RegExp _kEthAddressRegExp = RegExp(r'^0x[0-9a-fA-F]{40}$'); + +bool _isEthereumUsdtUri(String paymentUri) { + final trimmed = paymentUri.trim(); + if (trimmed.toLowerCase().startsWith('ethereum:')) return true; + return _kEthAddressRegExp.hasMatch(trimmed); +} + +// True if any wallet in [wallets] can send the given upper-cased [ticker] +// for the given [paymentUri]. USDT is special-cased to look at Ethereum +// wallets' token contracts, gated on the URI actually being ETH-chain. +bool hasShopInBitWalletForTicker({ + required Wallets wallets, + required String ticker, + required String paymentUri, +}) { + if (ticker == "USDT") { + if (!_isEthereumUsdtUri(paymentUri)) return false; + return wallets.wallets.any( + (w) => + w.info.coin is Ethereum && + w.info.tokenContractAddresses.contains(kShopInBitUsdtContractAddress), + ); + } + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin == null) return false; + return wallets.wallets.any((e) => e.info.coin == coin); +} + +// Pushes the send-from view and awaits it. +Future _pushShopInBitSendFrom({ + required BuildContext context, + required CryptoCurrency coin, + required Amount? amount, + required String address, + required ShopInBitOrderModel model, + EthContract? tokenContract, + bool popDesktopBeforeShow = false, + String? routeOnSuccessName, +}) async { + if (Util.isDesktop) { + if (popDesktopBeforeShow) { + Navigator.of(context, rootNavigator: true).pop(); + } + await showDialog( + context: context, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: model, + shouldPopRoot: true, + tokenContract: tokenContract, + ), + ); + } else { + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: model, + tokenContract: tokenContract, + routeOnSuccessName: routeOnSuccessName, + ), + settings: const RouteSettings(name: ShopInBitSendFromView.routeName), + ), + ); + } +} + +// Tries to launch the in-wallet send flow for [ticker]/[address]. +Future tryNavigateToShopInBitWalletSend({ + required WidgetRef ref, + required BuildContext context, + required String ticker, + required String paymentUri, + required String address, + required Amount? amount, + required ShopInBitOrderModel model, + bool popDesktopBeforeShow = false, + String? routeOnSuccessName, +}) async { + if (address.isEmpty) return false; + + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin != null) { + await _pushShopInBitSendFrom( + context: context, + coin: coin, + amount: amount, + address: address, + model: model, + popDesktopBeforeShow: popDesktopBeforeShow, + routeOnSuccessName: routeOnSuccessName, + ); + return true; + } + + if (ticker == "USDT") { + if (!_isEthereumUsdtUri(paymentUri)) return false; + final tokenContract = ref + .read(mainDBProvider) + .getEthContractSync(kShopInBitUsdtContractAddress); + if (tokenContract != null) { + final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); + if (ethCoin != null) { + await _pushShopInBitSendFrom( + context: context, + coin: ethCoin, + amount: amount, + address: address, + model: model, + tokenContract: tokenContract, + popDesktopBeforeShow: popDesktopBeforeShow, + routeOnSuccessName: routeOnSuccessName, + ); + return true; + } + } + } + + return false; +} + +// Fetches the live payment info for a ticket so the caller can pass it into +// the payment view as an arg (rather than loading it after the view is up). +// GET first to reuse an existing invoice per the spec's "page reload +// recovery" guidance; PUT (which regenerates) only when GET shows none. +// Returns null on any failure so the view can fall back to polling. +Future fetchShopInBitPaymentInfo( + WidgetRef ref, + int apiTicketId, +) async { + try { + final client = ref.read(pShopinBitService).client; + final getResp = await client.getPayment(apiTicketId); + if (!getResp.hasError && + getResp.value != null && + getResp.value!.paymentLinks.isNotEmpty) { + return getResp.value; + } + final putResp = await client.putPayment(apiTicketId); + if (!putResp.hasError && putResp.value != null) { + return putResp.value; + } + } catch (_) { + // Degrade to polling-only. + } + return null; +} + +// Shared mobile chrome for the two ShopInBit payment views: Background + +// PopScope (back goes through [onBack]) + AppBar + scrollable, intrinsic +// height body. +class ShopInBitPaymentMobileScaffold extends StatelessWidget { + const ShopInBitPaymentMobileScaffold({ + super.key, + required this.onBack, + required this.child, + }); + + final VoidCallback onBack; + final Widget child; + + @override + Widget build(BuildContext context) { + return Background( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (!didPop) { + onBack(); + } + }, + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton(onPressed: onBack), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: child), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 38895fd6f..6ea6d7f8f 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -1,52 +1,53 @@ import 'dart:async'; import 'dart:io'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; -import '../../route_generator.dart'; import '../../services/shopinbit/src/models/payment.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; -import '../../utilities/amount/amount.dart'; import '../../utilities/assets.dart'; +import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../widgets/background.dart'; -import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; -import '../../widgets/loading_indicator.dart'; +import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/rounded_white_container.dart'; -import 'shopinbit_send_from_view.dart'; +import 'shopinbit_payment_shared.dart'; class ShopInBitPaymentView extends ConsumerStatefulWidget { - const ShopInBitPaymentView({super.key, required this.model}); + const ShopInBitPaymentView({ + super.key, + required this.model, + this.initialPaymentInfo, + }); static const String routeName = "/shopInBitPayment"; final ShopInBitOrderModel model; + // Pre-loaded by the caller (see fetchShopInBitPaymentInfo) so the view can + // render populated immediately instead of fetching after it's pushed. + final PaymentInfo? initialPaymentInfo; + @override ConsumerState createState() => _ShopInBitPaymentViewState(); } class _ShopInBitPaymentViewState extends ConsumerState { - bool _loading = false; int _selectedMethod = 0; Timer? _pollTimer; @@ -78,9 +79,27 @@ class _ShopInBitPaymentViewState extends ConsumerState { @override void initState() { super.initState(); + if (widget.initialPaymentInfo != null) { + _applyPaymentInfo(widget.initialPaymentInfo!); + } if (widget.model.apiTicketId != 0) { - _loadPayment(); + // If the pre-load didn't hand us usable payment links, recover them: + // GET, then PUT to generate one. + if (_addresses.every((a) => a.isEmpty)) { + unawaited(_recoverPaymentInfo()); + } else { + _startPolling(); + } + } + } + + Future _recoverPaymentInfo() async { + final info = await fetchShopInBitPaymentInfo(ref, widget.model.apiTicketId); + if (!mounted) return; + if (info != null) { + setState(() => _applyPaymentInfo(info)); } + _startPolling(); } @override @@ -121,222 +140,121 @@ class _ShopInBitPaymentViewState extends ConsumerState { } catch (_) {} } - // The shipping view's PAY NOW button is the only path into this view today, - // but we still GET first per the 1.0.4 spec's "page reload recovery" - // guidance: if a live invoice already exists for this ticket, reuse it. PUT - // (which regenerates) only when GET shows there isn't one. An empty - // paymentLinks map covers all "no live invoice" cases the server returns - // (fresh ticket, expired, invalid) and a non-empty map covers everything - // worth preserving (live, paid, paid_late, processing). - Future _loadPayment() async { - setState(() => _loading = true); - try { - final client = ref.read(pShopinBitService).client; - final getResp = await client.getPayment(widget.model.apiTicketId); - PaymentInfo? info; - if (!getResp.hasError && - getResp.value != null && - getResp.value!.paymentLinks.isNotEmpty) { - info = getResp.value!; - } else { - final putResp = await client.putPayment(widget.model.apiTicketId); - if (!putResp.hasError && putResp.value != null) { - info = putResp.value!; - } - } - if (info != null) { - _applyPaymentInfo(info); - } - } catch (_) { - // Fall back to local/dummy data - } finally { - if (mounted) { - setState(() => _loading = false); - _startPolling(); - } - } - } - Future _refreshInvoice() async { - setState(() => _loading = true); - try { - final resp = await ref + _pollTimer?.cancel(); + final resp = await showLoading( + whileFuture: ref .read(pShopinBitService) .client - .putPayment(widget.model.apiTicketId); - if (!resp.hasError && resp.value != null) { - _applyPaymentInfo(resp.value!); - } - } catch (_) {} - if (mounted) { - setState(() => _loading = false); - _startPolling(); + .putPayment(widget.model.apiTicketId), + context: context, + message: "Refreshing invoice", + ); + if (!mounted) return; + if (resp != null && !resp.hasError && resp.value != null) { + setState(() => _applyPaymentInfo(resp.value!)); } + _startPolling(); } Future _checkForPayment() async { _pollTimer?.cancel(); - setState(() => _loading = true); - try { - final resp = await ref + final resp = await showLoading( + whileFuture: ref .read(pShopinBitService) .client - .getPayment(widget.model.apiTicketId); - if (!resp.hasError && resp.value != null && mounted) { - setState(() => _applyPaymentInfo(resp.value!)); - final status = resp.value!.status; - if (const { - 'paid', - 'paid_over', - 'paid_late', - 'payment_processing', - }.contains(status)) { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Payment received!", - context: context, - ), - ); - } - } else if (status == 'underpaid') { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.", - context: context, - ), - ); - } - } else { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "No payment detected yet.", - context: context, - ), - ); - } - } - } else if (mounted) { + .getPayment(widget.model.apiTicketId), + context: context, + message: "Checking for payment", + ); + if (!mounted) return; + + if (resp != null && !resp.hasError && resp.value != null) { + setState(() => _applyPaymentInfo(resp.value!)); + final status = resp.value!.status; + if (const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + }.contains(status)) { unawaited( showFloatingFlushBar( - type: FlushBarType.warning, - message: resp.exception?.message ?? "Failed to check payment.", + type: FlushBarType.success, + message: "Payment received!", context: context, ), ); - } - } catch (e) { - if (mounted) { + } else if (status == 'underpaid') { unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: e.toString(), + message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "No payment detected yet.", context: context, ), ); } - } finally { - if (mounted) { - setState(() => _loading = false); - if (!_isTerminal) { - _startPolling(); - } - } + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: resp?.exception?.message ?? "Failed to check payment.", + context: context, + ), + ); + } + + if (!_isTerminal) { + _startPolling(); } } - void _confirmPayment() { + Future _confirmPayment() async { _pollTimer?.cancel(); final method = _methods[_selectedMethod]; final ticker = method.toUpperCase(); - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - - String address = ""; - Amount? amount; - EthContract? tokenContract; - - if (_currentAddress.isNotEmpty) { - final parsed = AddressUtils.parsePaymentUri(_currentAddress); - - if (parsed?.address != null && parsed!.address.isNotEmpty) { - address = parsed.address; - } else { - final raw = _currentAddress; - final colonIdx = raw.indexOf(':'); - if (colonIdx != -1) { - final afterScheme = raw.substring(colonIdx + 1); - final qIdx = afterScheme.indexOf('?'); - address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; - } else { - address = raw; - } - } - - String? amountStr = parsed?.amount; - if (amountStr == null || amountStr.isEmpty) { - final uri = Uri.tryParse(_currentAddress); - if (uri != null) { - amountStr = uri.queryParameters['amount']; - } - } - if (amountStr == null || amountStr.isEmpty) { - amountStr = _paymentInfo?.due; - } - - final int fractionDigits; - if (coin != null) { - fractionDigits = coin.fractionDigits; - } else if (ticker == "USDT") { - fractionDigits = 6; - } else { - fractionDigits = 8; - } - - if (amountStr != null && amountStr.isNotEmpty) { - try { - amount = Amount.fromDecimal( - Decimal.parse(amountStr), - fractionDigits: fractionDigits, - ); - } catch (_) {} - } - } + final target = parseShopInBitPaymentTarget( + paymentUri: _currentAddress, + ticker: ticker, + coin: AppConfig.getCryptoCurrencyForTicker(ticker), + amountFallback: _paymentInfo?.due, + ); - if (coin != null && address.isNotEmpty) { - _navigateToSendFrom(coin: coin, amount: amount, address: address); + if (await tryNavigateToShopInBitWalletSend( + ref: ref, + context: context, + ticker: ticker, + paymentUri: _currentAddress, + address: target.address, + amount: target.amount, + model: widget.model, + popDesktopBeforeShow: true, + )) { return; } - - if (ticker == "USDT" && address.isNotEmpty) { - const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - tokenContract = ref.read(mainDBProvider).getEthContractSync(usdtAddress); - if (tokenContract != null) { - final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); - if (ethCoin != null) { - _navigateToSendFrom( - coin: ethCoin, - amount: amount, - address: address, - tokenContract: tokenContract, - ); - return; - } - } - } - - widget.model.status = ShopInBitOrderStatus.paymentPending; - widget.model.paymentMethod = method; - - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - } else { - Navigator.of(context).popUntil((route) => route.isFirst); + if (!mounted) return; + + // Couldn't launch the in-wallet send. + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Payment details for $ticker aren't ready yet. " + "Please wait a moment or refresh the invoice.", + context: context, + ), + ); + if (!_isTerminal) { + _startPolling(); } } @@ -352,64 +270,6 @@ class _ShopInBitPaymentViewState extends ConsumerState { } } - void _navigateToSendFrom({ - required CryptoCurrency coin, - required Amount? amount, - required String address, - EthContract? tokenContract, - }) { - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: widget.model, - shouldPopRoot: true, - tokenContract: tokenContract, - ), - ), - ); - } else { - Navigator.of(context).push( - RouteGenerator.getRoute( - shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: widget.model, - tokenContract: tokenContract, - ), - settings: const RouteSettings(name: ShopInBitSendFromView.routeName), - ), - ); - } - } - - bool _hasWalletForTicker(String ticker) { - if (ticker == "USDT") { - const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - return ref - .read(pWallets) - .wallets - .any( - (w) => - w.info.coin is Ethereum && - w.info.tokenContractAddresses.contains(usdtAddress), - ); - } else { - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - if (coin != null) { - return ref.read(pWallets).wallets.any((e) => e.info.coin == coin); - } - } - return false; - } - String? _parseBip21Amount(String bip21Uri) { final parsed = AddressUtils.parsePaymentUri(bip21Uri); String? amountStr = parsed?.amount; @@ -425,7 +285,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { void _onOwnedCoinTap(int methodIndex) { if (!_payNowEnabled) return; _selectedMethod = methodIndex; - _confirmPayment(); + unawaited(_confirmPayment()); } void _onUnownedCoinTap(int methodIndex) { @@ -462,9 +322,9 @@ class _ShopInBitPaymentViewState extends ConsumerState { ), ), const SizedBox(width: 8), - Icon( - Icons.copy, - size: 14, + CopyIcon( + width: 14, + height: 14, color: Theme.of( context, ).extension()!.accentColorBlue, @@ -491,12 +351,17 @@ class _ShopInBitPaymentViewState extends ConsumerState { Widget build(BuildContext context) { final isDesktop = Util.isDesktop; + final wallets = ref.watch(pWallets); // Build coin rows from _methods/_addresses final coinRows = []; for (int i = 0; i < _methods.length; i++) { final ticker = _methods[i].toUpperCase(); final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - final hasWallet = _hasWalletForTicker(ticker); + final hasWallet = hasShopInBitWalletForTicker( + wallets: wallets, + ticker: ticker, + paymentUri: _addresses[i], + ); final amountStr = _addresses[i].isNotEmpty ? _parseBip21Amount(_addresses[i]) : null; @@ -552,9 +417,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { if (hasWallet) Text("PAY NOW", style: STextStyles.link2(context)) else - Icon( - Icons.info_outline, - size: 18, + SvgPicture.asset( + Assets.svg.circleInfo, + width: 18, + height: 18, color: Theme.of( context, ).extension()!.textSubtitle2, @@ -746,12 +612,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { horizontal: 32, vertical: 8, ), - child: Stack( - children: [ - SingleChildScrollView(child: content), - if (_loading) const LoadingIndicator(width: 24, height: 24), - ], - ), + child: SingleChildScrollView(child: content), ), ), ], @@ -759,46 +620,9 @@ class _ShopInBitPaymentViewState extends ConsumerState { ); } - return Background( - child: PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, dynamic result) { - if (!didPop) { - _popToTickets(); - } - }, - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton(onPressed: _popToTickets), - title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), - ), - ), - ), - if (_loading) const LoadingIndicator(width: 24, height: 24), - ], - ); - }, - ), - ), - ), - ), + return ShopInBitPaymentMobileScaffold( + onBack: _popToTickets, + child: content, ); } } diff --git a/lib/pages/shopinbit/shopinbit_setup_view.dart b/lib/pages/shopinbit/shopinbit_setup_view.dart index 1ce525f25..b02d5f19f 100644 --- a/lib/pages/shopinbit/shopinbit_setup_view.dart +++ b/lib/pages/shopinbit/shopinbit_setup_view.dart @@ -11,6 +11,7 @@ import '../../utilities/text_styles.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; import 'shopinbit_step_2.dart'; @@ -141,7 +142,10 @@ class _ShopInBitSetupViewState extends ConsumerState { ), ), IconButton( - icon: const Icon(Icons.copy, size: 20), + icon: const CopyIcon( + width: 20, + height: 20, + ), onPressed: () { Clipboard.setData( ClipboardData(text: key), diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 298e29369..d62e9b432 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -8,6 +8,7 @@ import 'package:flutter_svg/svg.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/address.dart'; +import '../../services/shopinbit/src/models/payment.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; @@ -15,10 +16,12 @@ import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/detail_item.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; +import 'shopinbit_payment_shared.dart'; import 'shopinbit_payment_view.dart'; class ShopInBitShippingView extends ConsumerStatefulWidget { @@ -63,6 +66,10 @@ class _ShopInBitShippingViewState extends ConsumerState { List> _countries = []; String? _selectedCountryIso; bool _loadingCountries = false; + // True when we arrived with a pre-set delivery country (the normal new-order + // path). Restored-from-API orders land here with no country, so we unlock + // the dropdown only in that case. + late final bool _countryLocked; bool _submitting = false; @@ -109,6 +116,7 @@ class _ShopInBitShippingViewState extends ConsumerState { _selectedCountryIso = widget.model.deliveryCountry.isNotEmpty ? widget.model.deliveryCountry : null; + _countryLocked = _selectedCountryIso != null; for (final node in [ _nameFocusNode, @@ -180,7 +188,16 @@ class _ShopInBitShippingViewState extends ConsumerState { postalCode: postalCode, country: country, ); + // Keep deliveryCountry authoritative and in sync with the shipping + // country. No-op when it was already set (the normal flow); fills the gap + // for restored orders, where deliveryCountry came back empty from the API + // and the user picked one here. + widget.model.deliveryCountry = country; + // Pre-load the payment info before pushing the payment view so it renders + // populated immediately. The Continue button's spinner (_submitting) + // already covers this wait. + PaymentInfo? paymentInfo; if (widget.model.apiTicketId != 0) { setState(() => _submitting = true); try { @@ -227,6 +244,11 @@ class _ShopInBitShippingViewState extends ConsumerState { // Sandbox may fail here; continue anyway. debugPrint("submitAddress failed: ${resp.exception?.message}"); } + + paymentInfo = await fetchShopInBitPaymentInfo( + ref, + widget.model.apiTicketId, + ); } catch (e) { debugPrint("submitAddress threw: $e"); } finally { @@ -237,9 +259,143 @@ class _ShopInBitShippingViewState extends ConsumerState { if (!mounted) return; unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitPaymentView.routeName, arguments: widget.model), + Navigator.of(context).pushNamed( + ShopInBitPaymentView.routeName, + arguments: (widget.model, paymentInfo), + ), + ); + } + + // Read-only display of the locked delivery country: it was fixed when the + // offer was priced and can't change here. + Widget _buildLockedCountryField() { + final label = + _countries + .where((c) => c['iso'] == _selectedCountryIso) + .map((c) => c['label'] as String) + .firstOrNull ?? + (_selectedCountryIso ?? ""); + + return DetailItem( + title: "Country", + detail: label, + disableSelectableText: true, + ); + } + + // Editable, searchable country dropdown. Only shown when the delivery country + // wasn't pre-set (restored-from-API orders). + Widget _buildCountryDropdown( + BuildContext context, { + required bool isDesktop, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), + child: DropdownButtonHideUnderline( + child: DropdownButton2( + value: _selectedCountryIso, + items: _countries + .map( + (c) => DropdownMenuItem( + value: c['iso'] as String, + child: Text( + c['label'] as String, + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + ) + : STextStyles.w500_14(context), + ), + ), + ) + .toList(), + onMenuStateChange: (isOpen) { + if (!isOpen) { + _countrySearchController.clear(); + } + }, + onChanged: _loadingCountries + ? null + : (value) => setState(() => _selectedCountryIso = value), + hint: Text( + _loadingCountries ? "Loading countries..." : "Country", + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + ) + : STextStyles.fieldLabel(context), + ), + isExpanded: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, 0), + elevation: 0, + maxHeight: 300, + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + dropdownSearchData: DropdownSearchData( + searchController: _countrySearchController, + searchInnerWidgetHeight: 48, + searchInnerWidget: TextFormField( + controller: _countrySearchController, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + hintText: "Search...", + hintStyle: STextStyles.fieldLabel(context), + border: InputBorder.none, + ), + ), + searchMatchFn: (item, searchValue) { + final label = _countries + .where((c) => c['iso'] == item.value) + .map((c) => c['label'] as String) + .firstOrNull; + return label?.toLowerCase().contains(searchValue.toLowerCase()) ?? + false; + }, + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ), ); } @@ -310,118 +466,25 @@ class _ShopInBitShippingViewState extends ConsumerState { ], ), spacing, - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: _selectedCountryIso, - items: _countries - .map( - (c) => DropdownMenuItem( - value: c['iso'] as String, - child: Text( - c['label'] as String, - style: isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ), - ) - .toList(), - onMenuStateChange: (isOpen) { - if (!isOpen) { - _countrySearchController.clear(); - } - }, - onChanged: null, - hint: Text( - "Country", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - maxHeight: 300, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - dropdownSearchData: DropdownSearchData( - searchController: _countrySearchController, - searchInnerWidgetHeight: 48, - searchInnerWidget: TextFormField( - controller: _countrySearchController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - hintText: "Search...", - hintStyle: STextStyles.fieldLabel(context), - border: InputBorder.none, - ), - ), - searchMatchFn: (item, searchValue) { - final label = _countries - .where((c) => c['iso'] == item.value) - .map((c) => c['label'] as String) - .firstOrNull; - return label?.toLowerCase().contains( - searchValue.toLowerCase(), - ) ?? - false; - }, - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), + // The delivery country was chosen when the offer was requested and the + // price (incl. shipping + VAT) was calculated from it, so it can't be + // changed here. Restored-from-API orders are the exception: they come + // back with no country, so we let the user supply one (and warn that it + // may not match what the offer was priced for). + if (_countryLocked) + _buildLockedCountryField() + else ...[ + _buildCountryDropdown(context, isDesktop: isDesktop), + SizedBox(height: isDesktop ? 8 : 6), + Text( + "This order was started on another device. Choosing a country " + "here may not match the delivery destination the offer was " + "priced for.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), ), - ), + ], spacing, // Billing address toggle. GestureDetector( diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index fa374fe3c..65a0b8e19 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:intl/intl.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; @@ -12,6 +13,7 @@ import '../../providers/global/shopin_bit_orders_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/shopinbit_orders_service.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -509,8 +511,10 @@ class _ShopInBitTicketDetailState extends ConsumerState { if (!Util.isDesktop) IconButton( onPressed: _sendMessage, - icon: Icon( - Icons.send, + icon: SvgPicture.asset( + Assets.svg.send, + width: 24, + height: 24, color: Theme.of( context, ).extension()!.accentColorBlue, diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 2a42a8af7..df03fd581 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -262,6 +262,7 @@ import 'services/cakepay/src/models/order.dart'; import 'services/event_bus/events/global/node_connection_status_changed_event.dart'; import 'services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'services/shopinbit/src/models/car_research.dart'; +import 'services/shopinbit/src/models/payment.dart'; import 'utilities/amount/amount.dart'; import 'utilities/enums/add_wallet_type_enum.dart'; import 'wallets/crypto_currency/crypto_currency.dart'; @@ -1258,10 +1259,13 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitPaymentView.routeName: - if (args is ShopInBitOrderModel) { + if (args is (ShopInBitOrderModel, PaymentInfo?)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitPaymentView(model: args), + builder: (_) => ShopInBitPaymentView( + model: args.$1, + initialPaymentInfo: args.$2, + ), settings: RouteSettings(name: settings.name), ); } diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart index f625a63fe..e5561b4e2 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -196,16 +196,19 @@ abstract final class NestedNavigatorDialogRouteGenerator { ); case ShopInBitPaymentView.routeName: - if (args is ShopInBitOrderModel) { + if (args is (ShopInBitOrderModel, PaymentInfo?)) { return getRoute( - builder: (_) => ShopInBitPaymentView(model: args), + builder: (_) => ShopInBitPaymentView( + model: args.$1, + initialPaymentInfo: args.$2, + ), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected (ShopInBitOrderModel, PaymentInfo?)", ); case CakePayVendorsView.routeName: