From adcbf651da3ece723f0b63253e9a22d498e2f3f1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:19:42 -0500 Subject: [PATCH 01/27] refactor: extract shared payment flow --- .../shopinbit_car_research_payment_view.dart | 195 ++----------- .../shopinbit/shopinbit_payment_shared.dart | 271 ++++++++++++++++++ .../shopinbit/shopinbit_payment_view.dart | 200 ++----------- .../shopinbit/shopinbit_shipping_view.dart | 11 +- 4 files changed, 323 insertions(+), 354 deletions(-) create mode 100644 lib/pages/shopinbit/shopinbit_payment_shared.dart diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 0d3d3a14a..40c366d61 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -1,28 +1,20 @@ 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'; @@ -32,7 +24,7 @@ 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 { @@ -101,78 +93,24 @@ class _ShopInBitCarResearchPaymentViewState 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 = tryNavigateToShopInBitWalletSend( + ref: ref, + context: context, + ticker: ticker, + 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; // 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 +126,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 +629,7 @@ 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(ref.watch(pWallets), ticker); final methodSelector = _methods.length <= 1 ? Padding( @@ -985,41 +864,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_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart new file mode 100644 index 000000000..d15e22ee2 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -0,0 +1,271 @@ +import 'dart:async'; + +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/providers.dart'; +import '../../route_generator.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 '../../widgets/loading_indicator.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); +} + +// True if any wallet in [wallets] can send the given upper-cased [ticker]. +// USDT is special-cased to look at Ethereum wallets' token contracts. +bool hasShopInBitWalletForTicker(Wallets wallets, String ticker) { + if (ticker == "USDT") { + 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); +} + +void _pushShopInBitSendFrom({ + required BuildContext context, + required CryptoCurrency coin, + required Amount? amount, + required String address, + required ShopInBitOrderModel model, + EthContract? tokenContract, + bool popDesktopBeforeShow = false, + String? routeOnSuccessName, +}) { + if (Util.isDesktop) { + if (popDesktopBeforeShow) { + Navigator.of(context, rootNavigator: true).pop(); + } + unawaited( + showDialog( + context: context, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: model, + shouldPopRoot: true, + tokenContract: tokenContract, + ), + ), + ); + } else { + 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]. Returns +// true when navigation happened. Returns false when no compatible wallet +// or token contract was found, leaving the caller to handle the +// "pay externally" path (flushbar, status change, etc). +bool tryNavigateToShopInBitWalletSend({ + required WidgetRef ref, + required BuildContext context, + required String ticker, + required String address, + required Amount? amount, + required ShopInBitOrderModel model, + bool popDesktopBeforeShow = false, + String? routeOnSuccessName, +}) { + if (address.isEmpty) return false; + + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin != null) { + _pushShopInBitSendFrom( + context: context, + coin: coin, + amount: amount, + address: address, + model: model, + popDesktopBeforeShow: popDesktopBeforeShow, + routeOnSuccessName: routeOnSuccessName, + ); + return true; + } + + if (ticker == "USDT") { + final tokenContract = ref + .read(mainDBProvider) + .getEthContractSync(kShopInBitUsdtContractAddress); + if (tokenContract != null) { + final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); + if (ethCoin != null) { + _pushShopInBitSendFrom( + context: context, + coin: ethCoin, + amount: amount, + address: address, + model: model, + tokenContract: tokenContract, + popDesktopBeforeShow: popDesktopBeforeShow, + routeOnSuccessName: routeOnSuccessName, + ); + return true; + } + } + } + + return false; +} + +// Shared mobile chrome for the two ShopInBit payment views: Background + +// PopScope (back goes through [onBack]) + AppBar + scrollable, intrinsic +// height body. Set [showLoading] to overlay a spinner. +class ShopInBitPaymentMobileScaffold extends StatelessWidget { + const ShopInBitPaymentMobileScaffold({ + super.key, + required this.onBack, + required this.child, + this.showLoading = false, + }); + + final VoidCallback onBack; + final Widget child; + final bool showLoading; + + @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 Stack( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: child), + ), + ), + ), + if (showLoading) + const LoadingIndicator(width: 24, height: 24), + ], + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 38895fd6f..f9216cfff 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -1,37 +1,30 @@ 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/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/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}); @@ -255,81 +248,25 @@ class _ShopInBitPaymentViewState extends ConsumerState { 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 (tryNavigateToShopInBitWalletSend( + ref: ref, + context: context, + ticker: ticker, + 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; @@ -352,64 +289,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; @@ -491,12 +370,13 @@ 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, ticker); final amountStr = _addresses[i].isNotEmpty ? _parseBip21Amount(_addresses[i]) : null; @@ -759,46 +639,10 @@ 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, + showLoading: _loading, + child: content, ); } } diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 298e29369..597656f68 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -63,6 +63,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 +113,7 @@ class _ShopInBitShippingViewState extends ConsumerState { _selectedCountryIso = widget.model.deliveryCountry.isNotEmpty ? widget.model.deliveryCountry : null; + _countryLocked = _selectedCountryIso != null; for (final node in [ _nameFocusNode, @@ -341,9 +346,11 @@ class _ShopInBitShippingViewState extends ConsumerState { _countrySearchController.clear(); } }, - onChanged: null, + onChanged: (_countryLocked || _loadingCountries) + ? null + : (value) => setState(() => _selectedCountryIso = value), hint: Text( - "Country", + _loadingCountries ? "Loading countries..." : "Country", style: isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context) From 738dc1e42b1a9e3cabb9c807dcf74b8effb4fc47 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:23:33 -0500 Subject: [PATCH 02/27] fix: use CopyIcon --- .../shopinbit/shopinbit_car_research_payment_view.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 40c366d61..a7f43dced 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -19,6 +19,7 @@ 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'; @@ -780,9 +781,9 @@ class _ShopInBitCarResearchPaymentViewState : STextStyles.itemSubtitle12(context), ), const Spacer(), - Icon( - Icons.copy, - size: 14, + CopyIcon( + width: 14, + height: 14, color: Theme.of( context, ).extension()!.accentColorBlue, From c2bd57084f35cdf551e8275b6563debb6c7c250a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:32:19 -0500 Subject: [PATCH 03/27] fix: guard against non-ETH TRON addresses --- .../shopinbit_car_research_payment_view.dart | 7 ++++- .../shopinbit/shopinbit_payment_shared.dart | 27 ++++++++++++++++--- .../shopinbit/shopinbit_payment_view.dart | 7 ++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index a7f43dced..484fed721 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -104,6 +104,7 @@ class _ShopInBitCarResearchPaymentViewState ref: ref, context: context, ticker: ticker, + paymentUri: _currentAddress, address: target.address, amount: target.amount, model: widget.model, @@ -630,7 +631,11 @@ class _ShopInBitCarResearchPaymentViewState ? _methods[_selectedMethod].toUpperCase() : ""; - final hasWallets = hasShopInBitWalletForTicker(ref.watch(pWallets), ticker); + final hasWallets = hasShopInBitWalletForTicker( + wallets: ref.watch(pWallets), + ticker: ticker, + paymentUri: _currentAddress, + ); final methodSelector = _methods.length <= 1 ? Padding( diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index d15e22ee2..fab7f8954 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -93,10 +93,29 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({ return ShopInBitPaymentTarget(address: address, amount: amount); } -// True if any wallet in [wallets] can send the given upper-cased [ticker]. -// USDT is special-cased to look at Ethereum wallets' token contracts. -bool hasShopInBitWalletForTicker(Wallets wallets, String ticker) { +// 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 && @@ -161,6 +180,7 @@ bool tryNavigateToShopInBitWalletSend({ required WidgetRef ref, required BuildContext context, required String ticker, + required String paymentUri, required String address, required Amount? amount, required ShopInBitOrderModel model, @@ -184,6 +204,7 @@ bool tryNavigateToShopInBitWalletSend({ } if (ticker == "USDT") { + if (!_isEthereumUsdtUri(paymentUri)) return false; final tokenContract = ref .read(mainDBProvider) .getEthContractSync(kShopInBitUsdtContractAddress); diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index f9216cfff..2ade2f5d4 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -259,6 +259,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { ref: ref, context: context, ticker: ticker, + paymentUri: _currentAddress, address: target.address, amount: target.amount, model: widget.model, @@ -376,7 +377,11 @@ class _ShopInBitPaymentViewState extends ConsumerState { for (int i = 0; i < _methods.length; i++) { final ticker = _methods[i].toUpperCase(); final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - final hasWallet = hasShopInBitWalletForTicker(wallets, ticker); + final hasWallet = hasShopInBitWalletForTicker( + wallets: wallets, + ticker: ticker, + paymentUri: _addresses[i], + ); final amountStr = _addresses[i].isNotEmpty ? _parseBip21Amount(_addresses[i]) : null; From cd2a1b889708e47731d87c75980f2b972dd9fe71 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:32:51 -0500 Subject: [PATCH 04/27] fix: use more SW-standard icons --- lib/pages/cakepay/cakepay_order_view.dart | 15 +++++++++------ lib/pages/cakepay/cakepay_orders_view.dart | 8 ++++++-- lib/pages/shopinbit/shopinbit_payment_view.dart | 14 ++++++++------ lib/pages/shopinbit/shopinbit_setup_view.dart | 6 +++++- lib/pages/shopinbit/shopinbit_ticket_detail.dart | 8 ++++++-- 5 files changed, 34 insertions(+), 17 deletions(-) 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_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 2ade2f5d4..fce38927a 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -22,6 +22,7 @@ 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/icon_widgets/copy_icon.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_payment_shared.dart'; @@ -342,9 +343,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, @@ -437,9 +438,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, 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_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, From 574392ab72654fe611b43983eca3409fbcd73323 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:56:39 -0500 Subject: [PATCH 05/27] refactor(shopinbit): await send-from navigation before returning true --- .../shopinbit_car_research_payment_view.dart | 7 ++-- .../shopinbit/shopinbit_payment_shared.dart | 42 ++++++++----------- .../shopinbit/shopinbit_payment_view.dart | 7 ++-- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 484fed721..af854f788 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -89,7 +89,7 @@ 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(); @@ -100,7 +100,7 @@ class _ShopInBitCarResearchPaymentViewState coin: AppConfig.getCryptoCurrencyForTicker(ticker), ); - final navigated = tryNavigateToShopInBitWalletSend( + final navigated = await tryNavigateToShopInBitWalletSend( ref: ref, context: context, ticker: ticker, @@ -113,6 +113,7 @@ class _ShopInBitCarResearchPaymentViewState ); 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 @@ -826,7 +827,7 @@ class _ShopInBitCarResearchPaymentViewState enabled: _payNowEnabled, onPressed: _payNowEnabled ? (hasWallets - ? _confirmPayment + ? () => unawaited(_confirmPayment()) : () => unawaited(_checkForPayment())) : null, ), diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index fab7f8954..c70f2531e 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -127,7 +125,8 @@ bool hasShopInBitWalletForTicker({ return wallets.wallets.any((e) => e.info.coin == coin); } -void _pushShopInBitSendFrom({ +// Pushes the send-from view and awaits it. +Future _pushShopInBitSendFrom({ required BuildContext context, required CryptoCurrency coin, required Amount? amount, @@ -136,26 +135,24 @@ void _pushShopInBitSendFrom({ EthContract? tokenContract, bool popDesktopBeforeShow = false, String? routeOnSuccessName, -}) { +}) async { if (Util.isDesktop) { if (popDesktopBeforeShow) { Navigator.of(context, rootNavigator: true).pop(); } - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: model, - shouldPopRoot: true, - tokenContract: tokenContract, - ), + await showDialog( + context: context, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: model, + shouldPopRoot: true, + tokenContract: tokenContract, ), ); } else { - Navigator.of(context).push( + await Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => ShopInBitSendFromView( @@ -172,11 +169,8 @@ void _pushShopInBitSendFrom({ } } -// Tries to launch the in-wallet send flow for [ticker]/[address]. Returns -// true when navigation happened. Returns false when no compatible wallet -// or token contract was found, leaving the caller to handle the -// "pay externally" path (flushbar, status change, etc). -bool tryNavigateToShopInBitWalletSend({ +// Tries to launch the in-wallet send flow for [ticker]/[address]. +Future tryNavigateToShopInBitWalletSend({ required WidgetRef ref, required BuildContext context, required String ticker, @@ -186,12 +180,12 @@ bool tryNavigateToShopInBitWalletSend({ required ShopInBitOrderModel model, bool popDesktopBeforeShow = false, String? routeOnSuccessName, -}) { +}) async { if (address.isEmpty) return false; final coin = AppConfig.getCryptoCurrencyForTicker(ticker); if (coin != null) { - _pushShopInBitSendFrom( + await _pushShopInBitSendFrom( context: context, coin: coin, amount: amount, @@ -211,7 +205,7 @@ bool tryNavigateToShopInBitWalletSend({ if (tokenContract != null) { final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); if (ethCoin != null) { - _pushShopInBitSendFrom( + await _pushShopInBitSendFrom( context: context, coin: ethCoin, amount: amount, diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index fce38927a..e876a7120 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -244,7 +244,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { } } - void _confirmPayment() { + Future _confirmPayment() async { _pollTimer?.cancel(); final method = _methods[_selectedMethod]; final ticker = method.toUpperCase(); @@ -256,7 +256,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { amountFallback: _paymentInfo?.due, ); - if (tryNavigateToShopInBitWalletSend( + if (await tryNavigateToShopInBitWalletSend( ref: ref, context: context, ticker: ticker, @@ -268,6 +268,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { )) { return; } + if (!mounted) return; widget.model.status = ShopInBitOrderStatus.paymentPending; widget.model.paymentMethod = method; @@ -306,7 +307,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { void _onOwnedCoinTap(int methodIndex) { if (!_payNowEnabled) return; _selectedMethod = methodIndex; - _confirmPayment(); + unawaited(_confirmPayment()); } void _onUnownedCoinTap(int methodIndex) { From fc57247daecf76a9bb60fd10702c5251a52826bd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 16:19:13 -0500 Subject: [PATCH 06/27] fix(ui): pre-load ShopInBit payment info instead of in-page spinner overlay --- lib/pages/shopinbit/shopinbit_offer_view.dart | 30 +-- .../shopinbit/shopinbit_payment_shared.dart | 56 ++++-- .../shopinbit/shopinbit_payment_view.dart | 184 +++++++----------- .../shopinbit/shopinbit_shipping_view.dart | 18 +- lib/route_generator.dart | 8 +- ...sted_navigator_dialog_route_generator.dart | 9 +- 6 files changed, 143 insertions(+), 162 deletions(-) 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 index c70f2531e..bd6aade79 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -5,8 +5,10 @@ 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'; @@ -17,7 +19,6 @@ 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/loading_indicator.dart'; import 'shopinbit_send_from_view.dart'; final String kShopInBitUsdtContractAddress = DefaultTokens.list @@ -223,20 +224,45 @@ Future tryNavigateToShopInBitWalletSend({ 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. Set [showLoading] to overlay a spinner. +// height body. class ShopInBitPaymentMobileScaffold extends StatelessWidget { const ShopInBitPaymentMobileScaffold({ super.key, required this.onBack, required this.child, - this.showLoading = false, }); final VoidCallback onBack; final Widget child; - final bool showLoading; @override Widget build(BuildContext context) { @@ -259,22 +285,16 @@ class ShopInBitPaymentMobileScaffold extends StatelessWidget { 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: child), - ), + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, ), + child: IntrinsicHeight(child: child), ), - if (showLoading) - const LoadingIndicator(width: 24, height: 24), - ], + ), ); }, ), diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index e876a7120..589e6f247 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -16,6 +16,7 @@ import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/assets.dart'; +import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/desktop/desktop_dialog.dart'; @@ -23,24 +24,30 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/icon_widgets/copy_icon.dart'; -import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.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; @@ -72,8 +79,13 @@ class _ShopInBitPaymentViewState extends ConsumerState { @override void initState() { super.initState(); + if (widget.initialPaymentInfo != null) { + _applyPaymentInfo(widget.initialPaymentInfo!); + } + // Poll even when the pre-load returned null so the view can still recover + // a live invoice on its own. if (widget.model.apiTicketId != 0) { - _loadPayment(); + _startPolling(); } } @@ -115,132 +127,80 @@ 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(); } } @@ -634,12 +594,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), ), ), ], @@ -649,7 +604,6 @@ class _ShopInBitPaymentViewState extends ConsumerState { return ShopInBitPaymentMobileScaffold( onBack: _popToTickets, - showLoading: _loading, child: content, ); } diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 597656f68..4dc567de5 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'; @@ -19,6 +20,7 @@ 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 { @@ -186,6 +188,10 @@ class _ShopInBitShippingViewState extends ConsumerState { country: 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 { @@ -232,6 +238,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 { @@ -242,9 +253,10 @@ 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), + ), ); } 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: From b4cb894985abd1619b3a18318d6053ce1a8b6ff9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 16:33:22 -0500 Subject: [PATCH 07/27] fix(shopinbit): don't pop the whole nav stack when PAY NOW has no address Auto stash before rebase of "josh/fixes" --- .../shopinbit/shopinbit_payment_view.dart | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 589e6f247..6ea6d7f8f 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -82,13 +82,26 @@ class _ShopInBitPaymentViewState extends ConsumerState { if (widget.initialPaymentInfo != null) { _applyPaymentInfo(widget.initialPaymentInfo!); } - // Poll even when the pre-load returned null so the view can still recover - // a live invoice on its own. if (widget.model.apiTicketId != 0) { - _startPolling(); + // 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 void dispose() { _pollTimer?.cancel(); @@ -230,13 +243,18 @@ class _ShopInBitPaymentViewState extends ConsumerState { } if (!mounted) 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); + // 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(); } } From 9f558ea23dafe31cb66d7b33e285ca32efbc3700 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 16:58:35 -0500 Subject: [PATCH 08/27] fix(shopinbit): keep delivery country consistent in shipping view --- .../shopinbit/shopinbit_shipping_view.dart | 301 +++++++++++------- 1 file changed, 188 insertions(+), 113 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 4dc567de5..344ebd856 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -187,6 +187,11 @@ 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) @@ -260,6 +265,171 @@ class _ShopInBitShippingViewState extends ConsumerState { ); } + // Read-only display of the locked delivery country. Looks like the other + // fields but isn't editable; the country was fixed when the offer was priced. + Widget _buildLockedCountryField( + BuildContext context, { + required bool isDesktop, + }) { + final label = + _countries + .where((c) => c['iso'] == _selectedCountryIso) + .map((c) => c['label'] as String) + .firstOrNull ?? + (_selectedCountryIso ?? ""); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Country", + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + ) + : STextStyles.fieldLabel(context), + ), + Text( + label, + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + ) + : STextStyles.w500_14(context), + ), + ], + ), + ); + } + + // 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), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; @@ -327,120 +497,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: (_countryLocked || _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), - ), - ), + // 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(context, isDesktop: isDesktop) + 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( From 1659182d6d7afc8dc0ea05efcd4684b075609f0e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 17:42:15 -0500 Subject: [PATCH 09/27] fix(shopinbit): render locked country as disabled text field --- .../shopinbit/shopinbit_shipping_view.dart | 49 ++++--------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 344ebd856..d62e9b432 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -16,6 +16,7 @@ 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'; @@ -265,12 +266,9 @@ class _ShopInBitShippingViewState extends ConsumerState { ); } - // Read-only display of the locked delivery country. Looks like the other - // fields but isn't editable; the country was fixed when the offer was priced. - Widget _buildLockedCountryField( - BuildContext context, { - required bool isDesktop, - }) { + // 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) @@ -278,39 +276,10 @@ class _ShopInBitShippingViewState extends ConsumerState { .firstOrNull ?? (_selectedCountryIso ?? ""); - return Container( - decoration: BoxDecoration( - color: Theme.of(context).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Country", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - Text( - label, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ], - ), + return DetailItem( + title: "Country", + detail: label, + disableSelectableText: true, ); } @@ -503,7 +472,7 @@ class _ShopInBitShippingViewState extends ConsumerState { // 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(context, isDesktop: isDesktop) + _buildLockedCountryField() else ...[ _buildCountryDropdown(context, isDesktop: isDesktop), SizedBox(height: isDesktop ? 8 : 6), From cf0b4437db71fc0537767ac96eebf535ee79a350 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 12:45:17 -0500 Subject: [PATCH 10/27] fix(shopinbit): show payment-check API errors as a blocking dialog --- lib/pages/shopinbit/shopinbit_payment_view.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 6ea6d7f8f..af80536f1 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -25,6 +25,7 @@ import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import 'shopinbit_payment_shared.dart'; class ShopInBitPaymentView extends ConsumerStatefulWidget { @@ -83,7 +84,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { _applyPaymentInfo(widget.initialPaymentInfo!); } if (widget.model.apiTicketId != 0) { - // If the pre-load didn't hand us usable payment links, recover them: + // 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()); @@ -203,13 +204,17 @@ class _ShopInBitPaymentViewState extends ConsumerState { ); } } else { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: resp?.exception?.message ?? "Failed to check payment.", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to check payment", + maxWidth: Util.isDesktop ? 500 : null, + message: resp?.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); + if (!mounted) return; } if (!_isTerminal) { From e691f22b3cf9e3628f5d178e262d86d145840bc7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 13:22:51 -0500 Subject: [PATCH 11/27] fix(shopinbit): show car research payment processing errors as a dialog --- .../shopinbit_car_research_payment_view.dart | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index af854f788..349b045ea 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -396,11 +396,14 @@ class _ShopInBitCarResearchPaymentViewState } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to submit car research request", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -419,11 +422,14 @@ class _ShopInBitCarResearchPaymentViewState if (logResp.hasError || logResp.value == null) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: logResp.exception?.message ?? "Failed to log payment", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to log car research payment", + maxWidth: Util.isDesktop ? 500 : null, + message: logResp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -520,11 +526,14 @@ class _ShopInBitCarResearchPaymentViewState } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to process car research payment", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } From d1e0a72a7371a12ab8684c9fddb2f3a69f63c965 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 14:01:08 -0500 Subject: [PATCH 12/27] fix(shopinbit): show car research request retry errors as a dialog --- .../shopinbit_car_research_payment_view.dart | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 349b045ea..fc24f8c43 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -566,11 +566,14 @@ class _ShopInBitCarResearchPaymentViewState if (reqResp.hasError || reqResp.value == null) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: reqResp.exception?.message ?? "Retry failed", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Retry failed", + maxWidth: Util.isDesktop ? 500 : null, + message: reqResp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -608,11 +611,14 @@ class _ShopInBitCarResearchPaymentViewState } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Retry failed", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } From 086831356d80e0cf483af4203f6e3d9afbda44e2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 14:38:42 -0500 Subject: [PATCH 13/27] fix(shopinbit): show car research invoice errors as a dialog --- .../shopinbit/shopinbit_car_fee_view.dart | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 3f2f48d08..8b4b2805b 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -25,6 +25,7 @@ import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; import '../more_view/services_view.dart'; import 'shopinbit_car_research_payment_view.dart'; @@ -259,15 +260,17 @@ class _ShopInBitCarFeeViewState extends ConsumerState { error: resp.exception, stackTrace: StackTrace.current, ); - // TODO: show error dialogs so users can easily see what happened and share with support without digging through logs if (mounted) { setState(() => _submitting = false); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: resp.exception?.message ?? "Failed to create invoice", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create invoice", + maxWidth: Util.isDesktop ? 500 : null, + message: resp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -302,14 +305,16 @@ class _ShopInBitCarFeeViewState extends ConsumerState { ); } catch (e, s) { Logging.instance.e("Create invoice failed", error: e, stackTrace: s); - // TODO: show error dialogs so users can easily see what happened and share with support without digging through logs if (mounted) { setState(() => _submitting = false); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create invoice", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } From c3e5340ccba5b2100f9368d5358c4f57a41f0cb3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 15:16:05 -0500 Subject: [PATCH 14/27] fix(shopinbit): show customer key generation errors as a dialog --- .../shopinbit/shopinbit_settings_view.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index 886ba4c8c..d9574f697 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -121,16 +121,21 @@ class _ShopInBitSettingsViewState extends ConsumerState { } } catch (e) { if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to generate key: $e", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to generate key", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, ), ); } } finally { - setState(() => _loading = false); + // Awaiting the error dialog above means the widget can unmount before + // we get here. + if (mounted) setState(() => _loading = false); } } From bc567af6266d50708770a005e946d50e3e0dded8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 15:53:29 -0500 Subject: [PATCH 15/27] fix(shopinbit): show manual customer key set errors as a dialog --- .../shopinbit/shopinbit_settings_view.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index d9574f697..bb92bcd9a 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -166,16 +166,21 @@ class _ShopInBitSettingsViewState extends ConsumerState { } } catch (e) { if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to set key: $e", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to set key", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, ), ); } } finally { - setState(() => _loading = false); + // Awaiting the error dialog above means the widget can unmount before + // we get here. + if (mounted) setState(() => _loading = false); } } From 05d6f8241bda7e2ac7f5450127a340ccc9e44817 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 16:31:44 -0500 Subject: [PATCH 16/27] fix(shopinbit): show ticket retry request errors as a dialog --- .../shopinbit/shopinbit_ticket_detail.dart | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 65a0b8e19..597c8bbc5 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -25,6 +25,7 @@ import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/refresh_control.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import 'shopinbit_offer_view.dart'; class ShopInBitTicketDetail extends ConsumerStatefulWidget { @@ -139,11 +140,14 @@ class _ShopInBitTicketDetailState extends ConsumerState { if (reqResp.hasError || reqResp.value == null) { if (mounted) { setState(() => _retrying = false); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: reqResp.exception?.message ?? "Failed to create request", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, + message: reqResp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -183,11 +187,14 @@ class _ShopInBitTicketDetailState extends ConsumerState { } catch (e) { if (mounted) { setState(() => _retrying = false); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } From c15dae48119d561de79780291ff65835d0e29862 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 17:08:22 -0500 Subject: [PATCH 17/27] fix(shopinbit): show step 4 submit errors as a dialog --- .../shopinbit_step4_submit.dart | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart index 9e0eedae6..ed95f8c99 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart @@ -4,8 +4,9 @@ import "package:flutter/material.dart"; import "../../../db/drift/shared_db/shared_database.dart"; import "../../../models/shopinbit/shopinbit_order_model.dart"; -import "../../../notifications/show_flush_bar.dart"; import "../../../services/shopinbit/shopinbit_service.dart"; +import "../../../utilities/util.dart"; +import "../../../widgets/stack_dialog.dart"; import "../shopinbit_order_created.dart"; /// Submits a ShopinBit request to the API and navigates to the order-created @@ -48,11 +49,14 @@ Future submitShopInBitRequest( if (resp.hasError) { if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: resp.exception?.message ?? "Failed to create request", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, + message: resp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -77,11 +81,14 @@ Future submitShopInBitRequest( ); } catch (e) { if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to create request: $e", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, ), ); } From 48de9d073833c53cd776a370ae9124f9cc693645 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 17:45:53 -0500 Subject: [PATCH 18/27] fix(cakepay): show missing-payment-data errors as a dialog --- lib/pages/cakepay/cakepay_order_view.dart | 29 ++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index 892d3b681..1f5cead0f 100644 --- a/lib/pages/cakepay/cakepay_order_view.dart +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -28,6 +28,7 @@ import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/qr.dart'; import '../../widgets/refresh_control.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import '../wallet_view/transaction_views/transaction_details_view.dart'; import 'cakepay_send_from_view.dart'; @@ -174,19 +175,31 @@ class _CakePayOrderViewState extends ConsumerState { final coin = _resolveCoin(option.ticker); if (option.address.trim().isEmpty) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: "No payment address available for $label", - context: context, + unawaited( + showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "No payment address available for $label", + maxWidth: Util.isDesktop ? 500 : null, + desktopPopRootNavigator: Util.isDesktop, + ), + ), ); return; } if (coin == null) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: "No wallet support for $label", - context: context, + unawaited( + showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "No wallet support for $label", + maxWidth: Util.isDesktop ? 500 : null, + desktopPopRootNavigator: Util.isDesktop, + ), + ), ); return; } From 13144c21c4e118179c6cf753cc1cf2af6f9bd5aa Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 18:23:16 -0500 Subject: [PATCH 19/27] chore(shopinbit): drop unused show_flush_bar import from car fee view --- lib/pages/shopinbit/shopinbit_car_fee_view.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 8b4b2805b..d6741e363 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../notifications/show_flush_bar.dart'; import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/address.dart'; From 9335dd5f00a28794ba67821b9e9902f20d258408 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 18:58:54 -0500 Subject: [PATCH 20/27] fix(shopinbit): require a live invoice before opening the payment view --- .../shopinbit/shopinbit_payment_view.dart | 47 ++---- .../shopinbit/shopinbit_shipping_view.dart | 140 +++++++++++------- lib/route_generator.dart | 4 +- ...sted_navigator_dialog_route_generator.dart | 6 +- 4 files changed, 105 insertions(+), 92 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index af80536f1..8f4105c9e 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -32,16 +32,15 @@ class ShopInBitPaymentView extends ConsumerStatefulWidget { const ShopInBitPaymentView({ super.key, required this.model, - this.initialPaymentInfo, + required this.paymentInfo, }); 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; + // Caller loads this before pushing, so we always open with usable addresses. + final PaymentInfo paymentInfo; @override ConsumerState createState() => @@ -80,27 +79,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { @override void initState() { super.initState(); - if (widget.initialPaymentInfo != null) { - _applyPaymentInfo(widget.initialPaymentInfo!); - } + _applyPaymentInfo(widget.paymentInfo); if (widget.model.apiTicketId != 0) { - // 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(); } - _startPolling(); } @override @@ -289,6 +271,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { void _onOwnedCoinTap(int methodIndex) { if (!_payNowEnabled) return; + if (_addresses[methodIndex].isEmpty) return; _selectedMethod = methodIndex; unawaited(_confirmPayment()); } @@ -362,14 +345,14 @@ class _ShopInBitPaymentViewState extends ConsumerState { for (int i = 0; i < _methods.length; i++) { final ticker = _methods[i].toUpperCase(); final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + final hasAddress = _addresses[i].isNotEmpty; final hasWallet = hasShopInBitWalletForTicker( wallets: wallets, ticker: ticker, paymentUri: _addresses[i], ); - final amountStr = _addresses[i].isNotEmpty - ? _parseBip21Amount(_addresses[i]) - : null; + final canPayNow = hasWallet && hasAddress; + final amountStr = hasAddress ? _parseBip21Amount(_addresses[i]) : null; if (i > 0) { coinRows.add(const SizedBox(height: 8)); @@ -378,11 +361,13 @@ class _ShopInBitPaymentViewState extends ConsumerState { coinRows.add( RoundedWhiteContainer( child: Opacity( - opacity: hasWallet ? 1.0 : 0.5, + opacity: canPayNow ? 1.0 : 0.5, child: InkWell( - onTap: hasWallet - ? () => _onOwnedCoinTap(i) - : () => _onUnownedCoinTap(i), + onTap: !hasAddress + ? null + : (hasWallet + ? () => _onOwnedCoinTap(i) + : () => _onUnownedCoinTap(i)), child: Row( children: [ if (coin != null) @@ -419,7 +404,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { ], ), ), - if (hasWallet) + if (canPayNow) Text("PAY NOW", style: STextStyles.link2(context)) else SvgPicture.asset( diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index d62e9b432..88be18d18 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -20,6 +20,7 @@ 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/stack_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; import 'shopinbit_payment_shared.dart'; import 'shopinbit_payment_view.dart'; @@ -194,70 +195,84 @@ class _ShopInBitShippingViewState extends ConsumerState { // 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. + // The payment view needs a live invoice, so load it here and only navigate + // once we have usable payment links. + if (widget.model.apiTicketId == 0) { + // No ticket, nothing to invoice. + await _showPaymentLoadError( + "This request isn't ready for payment yet. Please try again.", + ); + return; + } + PaymentInfo? paymentInfo; - if (widget.model.apiTicketId != 0) { - setState(() => _submitting = true); - try { - // Split name into first/last - final parts = name.split(' '); - final firstName = parts.first; - final lastName = parts.length > 1 ? parts.sublist(1).join(' ') : ''; - - Address? billingAddress; - if (_differentBilling) { - final billingName = _billingNameController.text.trim(); - final billingParts = billingName.split(' '); - final billingFirst = billingParts.first; - final billingLast = billingParts.length > 1 - ? billingParts.sublist(1).join(' ') - : ''; - billingAddress = Address( - firstName: billingFirst, - lastName: billingLast, - street: _billingStreetController.text.trim(), - zip: _billingPostalCodeController.text.trim(), - city: _billingCityController.text.trim(), - country: _billingSelectedCountryIso!, - ); - } - - final resp = await ref - .read(pShopinBitService) - .client - .submitAddress( - widget.model.apiTicketId, - shipping: Address( - firstName: firstName, - lastName: lastName, - street: street, - zip: postalCode, - city: city, - country: country, - ), - billing: billingAddress, - ); + setState(() => _submitting = true); + try { + // Split name into first/last + final parts = name.split(' '); + final firstName = parts.first; + final lastName = parts.length > 1 ? parts.sublist(1).join(' ') : ''; + + Address? billingAddress; + if (_differentBilling) { + final billingName = _billingNameController.text.trim(); + final billingParts = billingName.split(' '); + final billingFirst = billingParts.first; + final billingLast = billingParts.length > 1 + ? billingParts.sublist(1).join(' ') + : ''; + billingAddress = Address( + firstName: billingFirst, + lastName: billingLast, + street: _billingStreetController.text.trim(), + zip: _billingPostalCodeController.text.trim(), + city: _billingCityController.text.trim(), + country: _billingSelectedCountryIso!, + ); + } - if (resp.hasError) { - // Sandbox may fail here; continue anyway. - debugPrint("submitAddress failed: ${resp.exception?.message}"); - } + final resp = await ref + .read(pShopinBitService) + .client + .submitAddress( + widget.model.apiTicketId, + shipping: Address( + firstName: firstName, + lastName: lastName, + street: street, + zip: postalCode, + city: city, + country: country, + ), + billing: billingAddress, + ); - paymentInfo = await fetchShopInBitPaymentInfo( - ref, - widget.model.apiTicketId, - ); - } catch (e) { - debugPrint("submitAddress threw: $e"); - } finally { - if (mounted) setState(() => _submitting = false); + if (resp.hasError) { + // 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 { + if (mounted) setState(() => _submitting = false); } if (!mounted) return; + if (paymentInfo == null || paymentInfo.paymentLinks.isEmpty) { + // No live invoice; don't open a payment view with empty addresses. + await _showPaymentLoadError( + "We couldn't load the payment details for this order. " + "Please try again in a moment.", + ); + return; + } + unawaited( Navigator.of(context).pushNamed( ShopInBitPaymentView.routeName, @@ -266,6 +281,19 @@ class _ShopInBitShippingViewState extends ConsumerState { ); } + Future _showPaymentLoadError(String message) async { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Couldn't load payment details", + maxWidth: Util.isDesktop ? 500 : null, + message: message, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + // Read-only display of the locked delivery country: it was fixed when the // offer was priced and can't change here. Widget _buildLockedCountryField() { diff --git a/lib/route_generator.dart b/lib/route_generator.dart index df03fd581..9c98d6fb1 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1259,12 +1259,12 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitPaymentView.routeName: - if (args is (ShopInBitOrderModel, PaymentInfo?)) { + if (args is (ShopInBitOrderModel, PaymentInfo)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => ShopInBitPaymentView( model: args.$1, - initialPaymentInfo: args.$2, + paymentInfo: 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 e5561b4e2..f97e6f2b2 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,11 +196,11 @@ abstract final class NestedNavigatorDialogRouteGenerator { ); case ShopInBitPaymentView.routeName: - if (args is (ShopInBitOrderModel, PaymentInfo?)) { + if (args is (ShopInBitOrderModel, PaymentInfo)) { return getRoute( builder: (_) => ShopInBitPaymentView( model: args.$1, - initialPaymentInfo: args.$2, + paymentInfo: args.$2, ), settings: RouteSettings(name: settings.name), ); @@ -208,7 +208,7 @@ abstract final class NestedNavigatorDialogRouteGenerator { return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected (ShopInBitOrderModel, PaymentInfo?)", + "Expected (ShopInBitOrderModel, PaymentInfo)", ); case CakePayVendorsView.routeName: From bdb6f7aacc12749c010f79d1030753dfe8fde9ee Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:50:59 -0500 Subject: [PATCH 21/27] feat(shopinbit): add car request payload and invoice recovery to client --- lib/services/shopinbit/src/client.dart | 27 +++++++ .../shopinbit/src/models/car_research.dart | 79 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index ad695e241..1ca819785 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -355,12 +355,14 @@ class ShopInBitClient { Future> createCarResearchInvoice({ required Address billing, + CarResearchRequest? request, }) async { return _request( 'POST', '/car-research/invoice', body: { 'billing': billing.toJson(), + if (request != null) 'request': request.toJson(), if (_externalCustomerKey != null) 'external_customer_key': _externalCustomerKey, }, @@ -368,6 +370,31 @@ class ShopInBitClient { ); } + /// Unresolved car research invoices for the current partner/customer pair. + /// Used to recover a fee payment the user started but did not finish. + Future>> + getCurrentCarResearchInvoices() async { + return _requestRaw( + 'GET', + '/car-research/invoices/current', + parse: (body) { + if (body.isEmpty) return []; + final decoded = jsonDecode(body); + final list = decoded is List + ? decoded + : (decoded as Map)['invoices'] as List? ?? + const []; + return list + .map( + (e) => CarResearchCurrentInvoice.fromJson( + e as Map, + ), + ) + .toList(); + }, + ); + } + Future>> getCarResearchInvoiceStatus( String invoiceId, ) async { diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart index ea1eceb0d..e5bf15be3 100644 --- a/lib/services/shopinbit/src/models/car_research.dart +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -1,3 +1,82 @@ +/// Optional request payload cached with a car research fee invoice. When +/// provided, the backend creates the real car research ticket itself after the +/// fee is paid (the BTCPay webhook failsafe), so the client does not have to. +class CarResearchRequest { + final String customerPseudonym; + final String comment; + final String deliveryCountry; + + CarResearchRequest({ + required this.customerPseudonym, + required this.comment, + required this.deliveryCountry, + }); + + Map toJson() => { + 'customer_pseudonym': customerPseudonym, + 'comment': comment, + 'delivery_country': deliveryCountry, + }; +} + +/// An unresolved car research invoice returned by +/// GET /car-research/invoices/current, used to recover a payment the user +/// started but did not finish. +class CarResearchCurrentInvoice { + final String invoiceId; + final String status; + final String? additional; + final DateTime? expiresAt; + final Map paymentLinks; + final bool hasRequestPayload; + final DateTime? createdAt; + + CarResearchCurrentInvoice({ + required this.invoiceId, + required this.status, + required this.additional, + required this.expiresAt, + required this.paymentLinks, + required this.hasRequestPayload, + required this.createdAt, + }); + + factory CarResearchCurrentInvoice.fromJson(Map json) { + final linksRaw = json['payment_links'] as Map? ?? {}; + final expiresRaw = json['expires_at'] as String?; + final createdRaw = json['created_at'] as String?; + return CarResearchCurrentInvoice( + invoiceId: json['invoice_id'] as String, + status: json['status'] as String? ?? '', + additional: json['additional'] as String?, + expiresAt: expiresRaw == null ? null : DateTime.tryParse(expiresRaw), + paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), + hasRequestPayload: json['has_request_payload'] as bool? ?? false, + createdAt: createdRaw == null ? null : DateTime.tryParse(createdRaw), + ); + } +} + +/// Whether a car research invoice status counts as paid/finalized per the +/// ShopinBit 1.0.4 rules: Processing, Settled, or Expired with PaidLate. The +/// extra lowercase values keep older concierge-style statuses working. +bool carResearchIsFinalized(String? status, String? additional) { + final s = (status ?? '').toLowerCase().trim(); + final a = (additional ?? '').toLowerCase().trim(); + if (s == 'processing' || s == 'settled') return true; + if (s == 'expired' && a == 'paidlate') return true; + return const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + 'confirmed', + 'complete', + 'completed', + 'finalized', + }.contains(s); +} + class CarResearchInvoice { final String btcpayInvoice; final DateTime expiresAt; From fb4952db12491d83305a990d146079a14d9d05c7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:51:17 -0500 Subject: [PATCH 22/27] feat(shopinbit): cache car request payload when creating the fee invoice --- lib/pages/shopinbit/shopinbit_car_fee_view.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index d6741e363..1606d4ae0 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -248,10 +248,18 @@ class _ShopInBitCarFeeViewState extends ConsumerState { ); } + // Cache the car request alongside billing so the backend failsafe can + // create the real car research ticket once the fee is paid. + final request = CarResearchRequest( + customerPseudonym: widget.model.displayName, + comment: widget.model.requestDescription, + deliveryCountry: widget.model.deliveryCountry, + ); + final resp = await ref .read(pShopinBitService) .client - .createCarResearchInvoice(billing: billing); + .createCarResearchInvoice(billing: billing, request: request); if (resp.hasError || resp.value == null) { Logging.instance.e( From 8981054bba86230166f23154af4be55108123a11 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:53:36 -0500 Subject: [PATCH 23/27] refactor(shopinbit): finalize car research via backend failsafe --- .../shopinbit_car_research_payment_view.dart | 371 ++++-------------- lib/services/shopinbit/src/models/ticket.dart | 6 +- 2 files changed, 78 insertions(+), 299 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index fc24f8c43..138b331f8 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -10,6 +10,7 @@ import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../services/shopinbit/src/models/car_research.dart'; +import '../../services/shopinbit/src/models/ticket.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/logger.dart'; @@ -17,7 +18,6 @@ import '../../utilities/text_styles.dart'; import '../../utilities/util.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'; @@ -28,14 +28,7 @@ import 'shopinbit_order_created.dart'; import 'shopinbit_payment_shared.dart'; import 'shopinbit_tickets_view.dart'; -enum _PaymentFlowState { - idle, - polling, - loggingPayment, - creatingRequest, - complete, - error, -} +enum _PaymentFlowState { idle, polling, finalizing, complete, error } class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { const ShopInBitCarResearchPaymentView({ @@ -56,24 +49,11 @@ class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { class _ShopInBitCarResearchPaymentViewState extends ConsumerState { - static const Set _terminalStates = { - // concierge heritage - "paid", - "paid_over", - "paid_late", - "payment_processing", - // BTCPay / car research likely - "settled", - "confirmed", - "complete", - "completed", - "finalized", - }; - Timer? _pollTimer; Map? _status; _PaymentFlowState _flowState = _PaymentFlowState.idle; String _statusString = "ready_to_pay"; + String? _additional; List _methods = []; List _addresses = []; int _selectedMethod = 0; @@ -81,10 +61,7 @@ class _ShopInBitCarResearchPaymentViewState String get _currentAddress => _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : ""; - bool get _isTerminal { - final s = _statusString.toLowerCase().trim(); - return _terminalStates.contains(s); - } + bool get _isTerminal => carResearchIsFinalized(_statusString, _additional); bool get _payNowEnabled => !_isTerminal && _flowState == _PaymentFlowState.idle; @@ -135,7 +112,7 @@ class _ShopInBitCarResearchPaymentViewState try { await _pollStatus(); if (!mounted) return; - if (!_isTerminal && _flowState != _PaymentFlowState.loggingPayment) { + if (!_isTerminal && _flowState != _PaymentFlowState.finalizing) { unawaited( showFloatingFlushBar( type: FlushBarType.info, @@ -274,10 +251,11 @@ class _ShopInBitCarResearchPaymentViewState setState(() { _status = resp.value!; _statusString = _status!["status"]?.toString() ?? _statusString; + _additional = _status!["additional"]?.toString(); }); if (_isTerminal) { _pollTimer?.cancel(); - await _processPaymentAndRequest(); + await _finalizePayment(); } } catch (e) { if (mounted) { @@ -292,223 +270,77 @@ class _ShopInBitCarResearchPaymentViewState } } - Future _processPaymentAndRequest() async { - // Guard: only one entry allowed - if (_flowState == _PaymentFlowState.loggingPayment || - _flowState == _PaymentFlowState.creatingRequest || + Future _finalizePayment() async { + if (_flowState == _PaymentFlowState.finalizing || _flowState == _PaymentFlowState.complete || _flowState == _PaymentFlowState.error) { return; } - // Skip logCarResearchPayment if the fee was already logged. - final existingFeeTicket = widget.model.feeTicketNumber; - if (existingFeeTicket != null) { - if (!widget.model.needsCreateRequest) { - // Both steps already done: navigate to success directly. - if (!mounted) return; - setState(() => _flowState = _PaymentFlowState.complete); - - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); - - return; - } - // Fee logged; skip to createRequest. - setState(() => _flowState = _PaymentFlowState.creatingRequest); - _pollTimer?.cancel(); - try { - final customerKey = await ref - .read(pShopinBitService) - .ensureCustomerKey(); - final comment = - "${widget.model.requestDescription}\n\n" - "The Client paid the car research fee (#$existingFeeTicket)"; - final reqResp = await ref - .read(pShopinBitService) - .client - .createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); - if (reqResp.hasError || reqResp.value == null) { - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => StackDialog( - title: "Request Failed", - message: - "Payment was confirmed but we couldn't submit your car " - "research request. You can retry from My Requests.\n\n" - "Error: ${reqResp.exception?.message ?? 'Unknown error'}", - leftButton: SecondaryButton( - label: "Retry Now", - onPressed: () { - Navigator.of(ctx).pop(); - _retryCreateRequest(existingFeeTicket, customerKey); - }, - ), - rightButton: PrimaryButton( - label: "My Requests", - onPressed: () { - Navigator.of(ctx).pop(); - _popToTickets(); - }, - ), - ), - ); - } - return; - } - final requestRef = reqResp.value!; - final prevTicketId = widget.model.ticketId; - widget.model.apiTicketId = requestRef.id; - widget.model.ticketId = requestRef.number; - widget.model.status = ShopInBitOrderStatus.pending; - widget.model.isPendingPayment = false; - widget.model.needsCreateRequest = false; - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(widget.model.toCompanion()); - // Remove the sentinel record. - if (prevTicketId != null && prevTicketId != widget.model.ticketId) { - await (db.delete( - db.shopInBitTickets, - )..where((t) => t.ticketId.equals(prevTicketId))).go(); - } - if (!mounted) return; - setState(() => _flowState = _PaymentFlowState.complete); - - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); - } catch (e) { - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Failed to submit car research request", - maxWidth: Util.isDesktop ? 500 : null, - message: e.toString(), - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - } - return; - } - - setState(() => _flowState = _PaymentFlowState.loggingPayment); + setState(() => _flowState = _PaymentFlowState.finalizing); _pollTimer?.cancel(); + final db = ref.read(pSharedDrift); + final client = ref.read(pShopinBitService).client; + try { - final logResp = await ref - .read(pShopinBitService) - .client - .logCarResearchPayment(widget.invoice.btcpayInvoice); + // Best-effort: the BTCPay webhook is the failsafe that finalizes the fee + // and creates the receipt and real car ticket even if this call fails. + final logResp = await client.logCarResearchPayment( + widget.invoice.btcpayInvoice, + ); + if (logResp.hasError || logResp.value == null) { + // Payment is confirmed but we could not log it. The webhook will + // finalize it server side, so send the user to their requests where + // the finalized ticket will appear, and leave the pending record so + // they can resume if needed. if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); await showDialog( context: context, useRootNavigator: Util.isDesktop, builder: (context) => StackOkDialog( - title: "Failed to log car research payment", + title: "Payment received", maxWidth: Util.isDesktop ? 500 : null, - message: logResp.exception?.message, + message: + "We're finalizing your car research request. It will " + "appear in My Requests shortly.", desktopPopRootNavigator: Util.isDesktop, ), ); } + if (mounted) _popToTickets(); return; } - final feeResult = logResp.value!; - - // Persist feeTicketNumber on the existing model (a new DB row creates a - // spurious list entry). - widget.model.feeTicketNumber = feeResult.ticketNumber; - widget.model.needsCreateRequest = true; - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(widget.model.toCompanion()); - - if (!mounted) return; - setState(() => _flowState = _PaymentFlowState.creatingRequest); - - final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); - final comment = - "${widget.model.requestDescription}\n\n" - "The Client paid the car research fee (#${feeResult.ticketNumber})"; - - final reqResp = await ref - .read(pShopinBitService) - .client - .createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); + final result = logResp.value!; + widget.model.feeTicketNumber = result.ticketNumber; - if (reqResp.hasError || reqResp.value == null) { - // createRequest failed: fee receipt already persisted, show retry - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => StackDialog( - title: "Request Failed", - message: - "Payment was confirmed but we couldn't submit your car " - "research request. You can retry from My Requests.\n\n" - "Error: ${reqResp.exception?.message ?? 'Unknown error'}", - leftButton: SecondaryButton( - label: "Retry Now", - onPressed: () { - Navigator.of(ctx).pop(); - _retryCreateRequest(feeResult.ticketNumber, customerKey); - }, - ), - rightButton: PrimaryButton( - label: "My Requests", - onPressed: () { - Navigator.of(ctx).pop(); - _popToTickets(); - }, - ), - ), - ); - } - return; - } + // log-payment returns the partner-scoped fee receipt, which the customer + // key cannot poll. Adopt the customer-facing car research ticket the + // backend created from the cached request so polling targets it instead. + final realTicket = await _resolveRealTicket(result.ticketId); - final requestRef = reqResp.value!; final prevTicketId = widget.model.ticketId; - widget.model.apiTicketId = requestRef.id; - widget.model.ticketId = requestRef.number; + if (realTicket != null) { + widget.model.apiTicketId = realTicket.id; + widget.model.ticketId = realTicket.number; + } else { + // Backend has not surfaced the ticket yet. Show the receipt number and + // leave polling disabled so we don't hammer the inaccessible receipt; + // the requests list refresh will pick up the real ticket later. + widget.model.apiTicketId = 0; + widget.model.ticketId = result.ticketNumber; + } widget.model.status = ShopInBitOrderStatus.pending; widget.model.isPendingPayment = false; widget.model.needsCreateRequest = false; + await db .into(db.shopInBitTickets) .insertOnConflictUpdate(widget.model.toCompanion()); + + // Drop the sentinel pending row now that we have a real ticket id. if (prevTicketId != null && prevTicketId != widget.model.ticketId) { await (db.delete( db.shopInBitTickets, @@ -540,88 +372,32 @@ class _ShopInBitCarResearchPaymentViewState } } - Future _retryCreateRequest( - String feeTicketNumber, - String customerKey, - ) async { - if (_flowState == _PaymentFlowState.creatingRequest) return; - setState(() => _flowState = _PaymentFlowState.creatingRequest); - + /// Find the customer-facing car research ticket the backend created from the + /// cached request, excluding the partner-scoped fee receipt and any ticket we + /// already track. Returns the newest match, or null if none is visible yet. + Future _resolveRealTicket(int receiptTicketId) async { + final service = ref.read(pShopinBitService); + final db = ref.read(pSharedDrift); try { - final comment = - "${widget.model.requestDescription}\n\n" - "The Client paid the car research fee (#$feeTicketNumber)"; - - final reqResp = await ref - .read(pShopinBitService) - .client - .createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); - - if (reqResp.hasError || reqResp.value == null) { - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Retry failed", - maxWidth: Util.isDesktop ? 500 : null, - message: reqResp.exception?.message, - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - return; - } - - final requestRef = reqResp.value!; - widget.model.apiTicketId = requestRef.id; - widget.model.ticketId = requestRef.number; - widget.model.status = ShopInBitOrderStatus.pending; - // Flow complete: clear the resume flag before saving. - widget.model.isPendingPayment = false; - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(widget.model.toCompanion()); - - // Update fee receipt ticket - final feeTickets = await (db.select( - db.shopInBitTickets, - )..where((t) => t.ticketId.equals(feeTicketNumber))).get(); - if (feeTickets.isNotEmpty) { - final feeTicket = feeTickets.first.copyWith(needsCreateRequest: false); - await db.into(db.shopInBitTickets).insertOnConflictUpdate(feeTicket); - } - - if (!mounted) return; - setState(() => _flowState = _PaymentFlowState.complete); - - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); - } catch (e) { - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Retry failed", - maxWidth: Util.isDesktop ? 500 : null, - message: e.toString(), - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } + final customerKey = await service.ensureCustomerKey(); + final resp = await service.client.getTicketsByCustomer(customerKey); + if (resp.hasError || resp.value == null) return null; + + final knownApiIds = (await db.select(db.shopInBitTickets).get()) + .map((t) => t.apiTicketId) + .toSet(); + + final candidates = + resp.value! + .where( + (t) => t.id != receiptTicketId && !knownApiIds.contains(t.id), + ) + .toList() + ..sort((a, b) => b.id.compareTo(a.id)); + + return candidates.isEmpty ? null : candidates.first; + } catch (_) { + return null; } } @@ -835,8 +611,7 @@ class _ShopInBitCarResearchPaymentViewState PrimaryButton( label: _flowState == _PaymentFlowState.polling ? "Checking..." - : (_flowState == _PaymentFlowState.loggingPayment || - _flowState == _PaymentFlowState.creatingRequest) + : _flowState == _PaymentFlowState.finalizing ? "Processing..." : (hasWallets ? "PAY NOW" : "CHECK FOR PAYMENT"), enabled: _payNowEnabled, diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 1313d6032..773c0f478 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -162,5 +162,9 @@ class TicketFull { int _toInt(dynamic value) { if (value is int) return value; - return int.parse(value.toString()); + if (value is num) return value.toInt(); + // Un-priced offers come back with empty/missing numeric fields; returning 0 + // is safe as it's validated downstream and 0s result in an error dialog + // that pricing's unavailable. + return int.tryParse(value.toString()) ?? 0; } From 992d17e4501d148eef0f71a89dc6aaa0af2893a3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:55:00 -0500 Subject: [PATCH 24/27] feat(shopinbit): resume car research from server-side current invoices --- .../shopinbit/shopinbit_tickets_view.dart | 83 +++++++++++++++---- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index 8226f81bc..b267e3890 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -9,9 +9,11 @@ import "../../db/drift/shared_db/shared_database.dart"; import "../../models/shopinbit/shopinbit_order_model.dart"; import "../../providers/db/drift_provider.dart"; import "../../providers/global/shopin_bit_orders_provider.dart"; +import "../../providers/global/shopin_bit_service_provider.dart"; import "../../services/shopinbit/src/models/car_research.dart"; import "../../themes/stack_colors.dart"; import "../../utilities/assets.dart"; +import "../../utilities/show_loading.dart"; import "../../utilities/text_styles.dart"; import "../../utilities/util.dart"; import "../../widgets/background.dart"; @@ -74,34 +76,81 @@ class _ShopInBitTicketsViewState extends ConsumerState { } } - void _resumeFlow(ShopInBitTicket pending) { + Future _resumeFlow(ShopInBitTicket pending) async { final model = ShopInBitOrderModel.fromDriftRow(pending); + + // Recover the live invoice from the server first so resume works even if + // local invoice state was lost. + final response = await showLoading( + context: context, + rootNavigator: true, + message: "Checking your car research payment", + whileFuture: ref + .read(pShopinBitService) + .client + .getCurrentCarResearchInvoices(), + delay: const Duration(seconds: 1), + ); + if (!mounted) return; + + final invoice = _liveInvoiceFrom(response?.value, pending); + + if (invoice != null) { + await Navigator.of(context).pushNamed( + ShopInBitCarResearchPaymentView.routeName, + arguments: (model, invoice), + ); + } else { + // No recoverable invoice anywhere: re-create one from the fee view. + await Navigator.of( + context, + ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model); + } + } + + /// Pick a still-payable invoice, preferring the server's current invoices + /// and falling back to locally stored invoice state. + CarResearchInvoice? _liveInvoiceFrom( + List? current, + ShopInBitTicket pending, + ) { + if (current != null && current.isNotEmpty) { + final match = current.firstWhere( + (i) => i.invoiceId == pending.carResearchInvoiceId, + orElse: () => current.first, + ); + final payable = + match.expiresAt != null && + match.paymentLinks.isNotEmpty && + (match.expiresAt!.isAfter(DateTime.now()) || + carResearchIsFinalized(match.status, match.additional)); + if (payable) { + return CarResearchInvoice( + btcpayInvoice: match.invoiceId, + expiresAt: match.expiresAt!, + paymentLinks: match.paymentLinks, + ); + } + } + final expiresAt = pending.carResearchExpiresAt; final linksJson = pending.carResearchPaymentLinks; - + final invoiceId = pending.carResearchInvoiceId; if (expiresAt != null && expiresAt.isAfter(DateTime.now()) && - linksJson != null) { - // Invoice still live: navigate directly to payment view. + linksJson != null && + invoiceId != null) { final links = (jsonDecode(linksJson) as Map).map( (k, v) => MapEntry(k, v as String), ); - final invoice = CarResearchInvoice( - btcpayInvoice: pending.carResearchInvoiceId!, + return CarResearchInvoice( + btcpayInvoice: invoiceId, expiresAt: expiresAt, paymentLinks: links, ); - - Navigator.of(context).pushNamed( - ShopInBitCarResearchPaymentView.routeName, - arguments: (model, invoice), - ); - } else { - // Invoice expired: navigate to fee view. - Navigator.of( - context, - ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model); } + + return null; } static String _categoryLabel(ShopInBitCategory? category) => @@ -137,7 +186,7 @@ class _ShopInBitTicketsViewState extends ConsumerState { children.add( RoundedContainer( color: Theme.of(context).extension()!.popupBG, - onPressed: () => _resumeFlow(pending), + onPressed: () => unawaited(_resumeFlow(pending)), child: _RequestRow( title: "Car Research (In Progress)", subtitle: "Tap to continue your car research payment", From 1c503d992c494ceadd1ca15649e1221e572a4106 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:56:36 -0500 Subject: [PATCH 25/27] refactor(shopinbit): retire manual car research request retry --- .../shopinbit/shopinbit_ticket_detail.dart | 110 +++--------------- 1 file changed, 13 insertions(+), 97 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 597c8bbc5..e2eebf84f 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -7,7 +7,6 @@ import 'package:flutter_svg/svg.dart'; import 'package:intl/intl.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../notifications/show_flush_bar.dart'; import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_orders_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; @@ -25,7 +24,6 @@ import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/refresh_control.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; -import '../../widgets/stack_dialog.dart'; import 'shopinbit_offer_view.dart'; class ShopInBitTicketDetail extends ConsumerStatefulWidget { @@ -47,7 +45,6 @@ class _ShopInBitTicketDetailState extends ConsumerState { bool _polling = false; bool _sending = false; - bool _retrying = false; @override void initState() { @@ -115,92 +112,6 @@ class _ShopInBitTicketDetailState extends ConsumerState { } } - Future _retryCreateRequest() async { - if (_retrying) return; - setState(() => _retrying = true); - - try { - final model = _model; - final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); - final comment = - "${model.requestDescription}\n\n" - "The Client paid the car research fee (#${model.feeTicketNumber})"; - - final reqResp = await ref - .read(pShopinBitService) - .client - .createRequest( - customerPseudonym: model.displayName, - externalCustomerKey: customerKey, - serviceType: "car_research", - comment: comment, - deliveryCountry: model.deliveryCountry, - ); - - if (reqResp.hasError || reqResp.value == null) { - if (mounted) { - setState(() => _retrying = false); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Failed to create request", - maxWidth: Util.isDesktop ? 500 : null, - message: reqResp.exception?.message, - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - return; - } - - final requestRef = reqResp.value!; - final requestModel = ShopInBitOrderModel() - ..ticketId = requestRef.number - ..apiTicketId = requestRef.id - ..category = ShopInBitCategory.car - ..status = ShopInBitOrderStatus.pending - ..displayName = model.displayName - ..requestDescription = model.requestDescription - ..deliveryCountry = model.deliveryCountry; - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(requestModel.toCompanion()); - - model.needsCreateRequest = false; - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(model.toCompanion()); - - if (!mounted) return; - setState(() => _retrying = false); - - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Car research request submitted successfully!", - context: context, - ), - ); - Navigator.of(context).pop(); - } catch (e) { - if (mounted) { - setState(() => _retrying = false); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Failed to create request", - maxWidth: Util.isDesktop ? 500 : null, - message: e.toString(), - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - } - } - String _formatTime(DateTime dt) { final local = dt.toLocal(); final hour = local.hour.toString().padLeft(2, '0'); @@ -563,16 +474,21 @@ class _ShopInBitTicketDetailState extends ConsumerState { ) : const SizedBox.shrink(); - final retryButton = + // After the fee is paid the backend creates the real car ticket from the + // cached request, so we surface a finalizing note instead of asking the + // client to create the request itself. + final finalizingNote = model.needsCreateRequest && model.category == ShopInBitCategory.car ? Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: PrimaryButton( - label: _retrying ? "Submitting..." : "Complete Request", - enabled: !_retrying, - onPressed: _retrying - ? null - : () => unawaited(_retryCreateRequest()), + child: RoundedWhiteContainer( + child: Text( + "We're finalizing your car research request. Pull to refresh " + "if it doesn't appear shortly.", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), ), ) : const SizedBox.shrink(); @@ -582,7 +498,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { crossAxisAlignment: .stretch, children: [ statusBar, - retryButton, + finalizingNote, offerBanner, requestDetailsSection, chatArea, From 2d5c5b4fcb07f5a4b45b26292905b0a92c1b0141 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 14:05:44 -0500 Subject: [PATCH 26/27] fix(desktop settings): clamp selected menu index to prevent RangeError --- .../settings/desktop_settings_view.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pages_desktop_specific/settings/desktop_settings_view.dart b/lib/pages_desktop_specific/settings/desktop_settings_view.dart index 65569890d..a83bccbc4 100644 --- a/lib/pages_desktop_specific/settings/desktop_settings_view.dart +++ b/lib/pages_desktop_specific/settings/desktop_settings_view.dart @@ -119,10 +119,10 @@ class _DesktopSettingsViewState extends ConsumerState { ), ), Expanded( - child: - contentViews[ref - .watch(selectedSettingsMenuItemStateProvider.state) - .state], + child: contentViews[ + (ref.watch(selectedSettingsMenuItemStateProvider.state).state) + .clamp(0, contentViews.length - 1) + ], ), ], ), From 28cc575c7ad58c781bc4b47aab66da164f77ac7b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 22:47:37 -0500 Subject: [PATCH 27/27] refactor(shopinbit): resume car research with inline row spinner --- .../shopinbit/shopinbit_tickets_view.dart | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index b267e3890..0f02a30f2 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -13,7 +13,6 @@ import "../../providers/global/shopin_bit_service_provider.dart"; import "../../services/shopinbit/src/models/car_research.dart"; import "../../themes/stack_colors.dart"; import "../../utilities/assets.dart"; -import "../../utilities/show_loading.dart"; import "../../utilities/text_styles.dart"; import "../../utilities/util.dart"; import "../../widgets/background.dart"; @@ -21,6 +20,7 @@ import "../../widgets/conditional_parent.dart"; import "../../widgets/custom_buttons/app_bar_icon_button.dart"; import "../../widgets/desktop/desktop_dialog_close_button.dart"; import "../../widgets/dialogs/s_dialog.dart"; +import "../../widgets/loading_indicator.dart"; import "../../widgets/refresh_control.dart"; import "../../widgets/rounded_container.dart"; import "shopinbit_car_fee_view.dart"; @@ -42,6 +42,7 @@ class _ShopInBitTicketsViewState extends ConsumerState { ShopInBitTicket? _pendingTicket; StreamSubscription>? _ticketsSub; bool _refreshing = false; + bool _resuming = false; @override void initState() { @@ -77,23 +78,27 @@ class _ShopInBitTicketsViewState extends ConsumerState { } Future _resumeFlow(ShopInBitTicket pending) async { + if (_resuming) return; final model = ShopInBitOrderModel.fromDriftRow(pending); // Recover the live invoice from the server first so resume works even if // local invoice state was lost. - final response = await showLoading( - context: context, - rootNavigator: true, - message: "Checking your car research payment", - whileFuture: ref - .read(pShopinBitService) - .client - .getCurrentCarResearchInvoices(), - delay: const Duration(seconds: 1), - ); + setState(() => _resuming = true); + List? current; + try { + current = (await ref + .read(pShopinBitService) + .client + .getCurrentCarResearchInvoices()) + .value; + } catch (_) { + // Fall back to locally stored invoice state below. + } finally { + if (mounted) setState(() => _resuming = false); + } if (!mounted) return; - final invoice = _liveInvoiceFrom(response?.value, pending); + final invoice = _liveInvoiceFrom(current, pending); if (invoice != null) { await Navigator.of(context).pushNamed( @@ -186,14 +191,17 @@ class _ShopInBitTicketsViewState extends ConsumerState { children.add( RoundedContainer( color: Theme.of(context).extension()!.popupBG, - onPressed: () => unawaited(_resumeFlow(pending)), + onPressed: _resuming ? null : () => unawaited(_resumeFlow(pending)), child: _RequestRow( title: "Car Research (In Progress)", - subtitle: "Tap to continue your car research payment", + subtitle: _resuming + ? "Checking your car research payment..." + : "Tap to continue your car research payment", badgeText: "Resume", badgeColor: Theme.of( context, ).extension()!.accentColorYellow, + loading: _resuming, ), ), ); @@ -328,12 +336,14 @@ class _RequestRow extends StatelessWidget { required this.subtitle, required this.badgeText, required this.badgeColor, + this.loading = false, }); final String title; final String subtitle; final String badgeText; final Color badgeColor; + final bool loading; @override Widget build(BuildContext context) { @@ -374,12 +384,21 @@ class _RequestRow extends StatelessWidget { ), ), SizedBox(width: isDesktop ? 16 : 8), - SvgPicture.asset( - Assets.svg.chevronRight, - width: 20, - height: 20, - colorFilter: ColorFilter.mode(stackColors.textSubtitle1, .srcIn), - ), + loading + ? const SizedBox( + width: 20, + height: 20, + child: LoadingIndicator(), + ) + : SvgPicture.asset( + Assets.svg.chevronRight, + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + stackColors.textSubtitle1, + .srcIn, + ), + ), ], ); }