What changed, and why it matters
This commit is a large UI refactor of the Stack Wallet 'ShopInBit' payment flow. It extracts duplicated payment-method selection and QR-code dialogs into a shared widget, fixes some navigation bugs after a payment is sent, and tightens the USDT Ethereum-address detection. There is no obvious new security vulnerability in the diff, but the change is substantial and unreviewed, so it could contain subtle bugs.
Treat this as a normal but large feature/bugfix refactor. Have it code-reviewed by someone familiar with the ShopInBit flow, focusing on: (1) correct desktop/mobile navigation popping after a send, (2) that the new `ShopInBitPaymentMethodList` still disables payment actions when `_payNowEnabled` is false, (3) that removing `amountFallback` does not break invoices where the amount is only in `PaymentInfo.due`, and (4) that the stale-request guard covers all async callbacks. No immediate security patch is indicated by the diff alone.
Security signals we found
Large refactor (+490/-489) with no security explanation in commit message
Navigation logic changed in confirm-send flow; incorrect pop behavior could strand users on a sensitive screen
USDT Ethereum URI detection tightened to require an Ethereum address in the URI path, reducing risk of misrouting TRC20/USDT payments
Stale async response guard added via `_paymentRequestId` to prevent late poll results from overwriting current payment state
Amount parsing no longer falls back to `_paymentInfo?.due`, so send amount now strictly derives from the payment URI
Evidence from the diff
The patch refactors six ShopInBit Dart files. Key changes: (1) introduces a new reusable ShopInBitPaymentMethodList widget that consolidates coin-row rendering, QR display, copy-to-clipboard, and the ‘PAY NOW’ / ‘CHECK FOR PAYMENT’ actions; (2) removes the old inline method selector/QR/address UI from shopinbit_car_research_payment_view.dart and shopinbit_payment_view.dart; (3) fixes navigation in shopinbit_confirm_send_view.dart by adding an optional popThroughRouteName so the dialog stack is popped correctly on desktop; (4) adds routeSettings names to dialogs in shopinbit_payment_shared.dart and shopinbit_send_from_view.dart to support that navigation; (5) removes the amountFallback parameter from parseShopInBitPaymentTarget, so amounts now come only from the BIP21 URI; (6) renames _isEthereumUsdtUri to isShopInBitEthereumUsdtUri and makes it validate that an ethereum: URI actually contains an Ethereum address in its path; (7) adds a _paymentRequestId counter in shopinbit_payment_view.dart to drop stale async responses from earlier polling/check requests. The commit message is only ‘unreviewed’, so no security context is provided by the vendor.
Changed components
lib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/pages/shopinbit/shopinbit_payment_method_list.dartlib/pages/shopinbit/shopinbit_payment_shared.dartlib/pages/shopinbit/shopinbit_confirm_send_view.dartlib/pages/shopinbit/shopinbit_send_from_view.dartInspect captured patch +490 / −489
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index 9445683..6afe283 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app_config.dart';
@@ -9,10 +8,8 @@ import '../../db/drift/shared_db/shared_database.dart';
import '../../models/shopinbit/shopinbit_enums.dart';
import '../../notifications/show_flush_bar.dart';
import '../../providers/global/shopin_bit_service_provider.dart';
-import '../../providers/providers.dart';
import '../../services/shopinbit/shopinbit_api.dart';
import '../../themes/stack_colors.dart';
-import '../../utilities/assets.dart';
import '../../utilities/logger.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
@@ -20,13 +17,11 @@ 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_container.dart';
import '../../widgets/rounded_white_container.dart';
import '../../widgets/stack_dialog.dart';
import '../home_view/home_view.dart';
import 'shopinbit_order_created.dart';
+import 'shopinbit_payment_method_list.dart';
import 'shopinbit_payment_shared.dart';
import 'shopinbit_tickets_view.dart';
@@ -450,90 +445,16 @@ class _ShopInBitCarResearchPaymentViewState
);
}
- Future<void> _copyAddress(BuildContext context) async {
- final addr = _currentAddress;
- if (addr.isEmpty) return;
- await Clipboard.setData(ClipboardData(text: addr));
- if (!context.mounted) return;
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.info,
- message: "Copied to clipboard",
- iconAsset: Assets.svg.copy,
- context: context,
- ),
- );
+ void _onOwnedCoinTap(int methodIndex) {
+ if (!_payNowEnabled) return;
+ setState(() => _selectedMethod = methodIndex);
+ unawaited(_confirmPayment());
}
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
- final ticker = _selectedMethod < _methods.length
- ? _methods[_selectedMethod].toUpperCase()
- : "";
-
- final hasWallets = hasShopInBitWalletForTicker(
- wallets: ref.watch(pWallets),
- ticker: ticker,
- paymentUri: _currentAddress,
- );
-
- final methodSelector = _methods.length <= 1
- ? Padding(
- padding: const EdgeInsets.symmetric(vertical: 10),
- child: Text(
- _methods.isEmpty ? "" : _methods.first,
- textAlign: TextAlign.center,
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context),
- ),
- )
- : Row(
- children: List.generate(_methods.length, (index) {
- final isSelected = _selectedMethod == index;
- return Expanded(
- child: GestureDetector(
- onTap: () => setState(() => _selectedMethod = index),
- child: Container(
- padding: const EdgeInsets.symmetric(vertical: 10),
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(
- color: isSelected
- ? Theme.of(
- context,
- ).extension<StackColors>()!.accentColorBlue
- : Colors.transparent,
- width: 2,
- ),
- ),
- ),
- child: Text(
- _methods[index],
- textAlign: TextAlign.center,
- style:
- (isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- )
- : STextStyles.itemSubtitle12(context))
- .copyWith(
- color: isSelected
- ? Theme.of(context)
- .extension<StackColors>()!
- .accentColorBlue
- : null,
- fontWeight: isSelected ? FontWeight.w600 : null,
- ),
- ),
- ),
- ),
- );
- }),
- );
-
final content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: .min,
@@ -594,103 +515,27 @@ class _ShopInBitCarResearchPaymentViewState
),
),
SizedBox(height: isDesktop ? 24 : 16),
- methodSelector,
- SizedBox(height: isDesktop ? 24 : 16),
- if (_currentAddress.isNotEmpty)
- Center(
- child: QR(data: _currentAddress, size: isDesktop ? 200 : 180),
- )
- else
- Center(
- child: Padding(
- padding: const EdgeInsets.all(32),
- child: Text(
- "No payment address available",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- ),
- ),
- if (_currentAddress.isNotEmpty && _methods[_selectedMethod] == "USDT")
- SizedBox(height: isDesktop ? 24 : 16),
- if (_currentAddress.isNotEmpty && _methods[_selectedMethod] == "USDT")
- RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningBackground,
- child: Center(
- child: Text(
- "IMPORTANT: Only send USDT (TRX20) to this address, not TRX",
- style: (isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningForeground,
- )),
- ),
- ),
- ),
- SizedBox(height: isDesktop ? 16 : 12),
- if (_currentAddress.isNotEmpty)
- GestureDetector(
- onTap: () => _copyAddress(context),
- child: RoundedWhiteContainer(
- child: Column(
- children: [
- Row(
- children: [
- Text(
- "${_methods[_selectedMethod]} address",
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context),
- ),
- const Spacer(),
- CopyIcon(
- width: isDesktop ? 15 : 10,
- height: isDesktop ? 15 : 10,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.infoItemIcons,
- ),
- const SizedBox(width: 4),
- Text("Copy", style: STextStyles.link2(context)),
- ],
- ),
- const SizedBox(height: 4),
- Row(
- children: [
- Expanded(
- child: Text(
- _currentAddress,
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context),
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- if (!isDesktop) const Spacer(),
- if (isDesktop) const SizedBox(height: 24),
- PrimaryButton(
- label: _flowState == _PaymentFlowState.polling
- ? "Checking..."
- : _flowState == _PaymentFlowState.finalizing
- ? "Processing..."
- : (hasWallets ? "PAY NOW" : "CHECK FOR PAYMENT"),
+ ShopInBitPaymentMethodList(
+ methods: _methods,
+ addresses: _addresses,
enabled: _payNowEnabled,
- onPressed: _payNowEnabled
- ? (hasWallets
- ? () => unawaited(_confirmPayment())
- : () => unawaited(_checkForPayment()))
- : null,
+ onPayFromWallet: _onOwnedCoinTap,
+ onCheckForPayment: (methodIndex) {
+ _selectedMethod = methodIndex;
+ unawaited(_checkForPayment());
+ },
),
+ if (_flowState == _PaymentFlowState.polling ||
+ _flowState == _PaymentFlowState.finalizing) ...[
+ SizedBox(height: isDesktop ? 24 : 16),
+ PrimaryButton(
+ label: _flowState == _PaymentFlowState.polling
+ ? "Checking..."
+ : "Processing...",
+ enabled: false,
+ onPressed: null,
+ ),
+ ],
],
);
diff --git a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart
index 2587a17..83781fd 100644
--- a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart
+++ b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart
@@ -42,6 +42,7 @@ class ShopInBitConfirmSendView extends ConsumerStatefulWidget {
this.routeOnSuccessName = WalletView.routeName,
required this.apiTicketId,
this.tokenContract,
+ this.popThroughRouteName,
});
static const String routeName = "/shopInBitConfirmSend";
@@ -51,6 +52,7 @@ class ShopInBitConfirmSendView extends ConsumerStatefulWidget {
final String routeOnSuccessName;
final int apiTicketId;
final EthContract? tokenContract;
+ final String? popThroughRouteName;
@override
ConsumerState<ShopInBitConfirmSendView> createState() =>
@@ -127,15 +129,26 @@ class _ShopInBitConfirmSendViewState
// pop back to wallet
if (context.mounted) {
- // pop sending dialog (pushed via showDialog which uses root navigator)
- Navigator.of(context, rootNavigator: true).pop();
-
- if (Util.isDesktop) {
- // pop the confirm send desktop dialog
+ final popThroughRouteName = widget.popThroughRouteName;
+ if (popThroughRouteName != null) {
+ final navigator = Navigator.of(context, rootNavigator: true);
+ navigator.popUntil(
+ ModalRoute.withName(popThroughRouteName),
+ );
+ navigator.pop();
+ } else {
+ // pop sending dialog (pushed via showDialog which uses root navigator)
Navigator.of(context, rootNavigator: true).pop();
- }
- Navigator.of(context).popUntil(ModalRoute.withName(routeOnSuccessName));
+ if (Util.isDesktop) {
+ // pop the confirm send desktop dialog
+ Navigator.of(context, rootNavigator: true).pop();
+ }
+
+ Navigator.of(
+ context,
+ ).popUntil(ModalRoute.withName(routeOnSuccessName));
+ }
}
} catch (e, s) {
Logging.instance.e(
diff --git a/lib/pages/shopinbit/shopinbit_payment_method_list.dart b/lib/pages/shopinbit/shopinbit_payment_method_list.dart
new file mode 100644
index 0000000..58af371
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_payment_method_list.dart
@@ -0,0 +1,399 @@
+import 'dart:async';
+import 'dart:io';
+
+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';
+import '../../providers/providers.dart';
+import '../../themes/coin_icon_provider.dart';
+import '../../themes/stack_colors.dart';
+import '../../utilities/address_utils.dart';
+import '../../utilities/assets.dart';
+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/dialogs/s_dialog.dart';
+import '../../widgets/dialogs/simple_mobile_dialog.dart';
+import '../../widgets/icon_widgets/copy_icon.dart';
+import '../../widgets/qr.dart';
+import '../../widgets/rounded_container.dart';
+import '../../widgets/rounded_white_container.dart';
+import 'shopinbit_payment_shared.dart';
+
+class ShopInBitPaymentMethodList extends ConsumerStatefulWidget {
+ const ShopInBitPaymentMethodList({
+ super.key,
+ required this.methods,
+ required this.addresses,
+ required this.enabled,
+ required this.onPayFromWallet,
+ required this.onCheckForPayment,
+ });
+
+ final List<String> methods;
+ final List<String> addresses;
+ final bool enabled;
+ final ValueChanged<int> onPayFromWallet;
+ final ValueChanged<int> onCheckForPayment;
+
+ @override
+ ConsumerState<ShopInBitPaymentMethodList> createState() =>
+ _ShopInBitPaymentMethodListState();
+}
+
+class _ShopInBitPaymentMethodListState
+ extends ConsumerState<ShopInBitPaymentMethodList> {
+ int? _openIndex;
+ String? _openTicker;
+ String? _openAddress;
+ BuildContext? _dialogContext;
+ bool _dismissScheduled = false;
+
+ bool get _openPaymentIsCurrent {
+ final index = _openIndex;
+ return mounted &&
+ widget.enabled &&
+ index != null &&
+ index < widget.methods.length &&
+ index < widget.addresses.length &&
+ widget.methods[index].toUpperCase() == _openTicker &&
+ widget.addresses[index] == _openAddress;
+ }
+
+ void _dismissPaymentDetails() {
+ final dialogContext = _dialogContext;
+ if (dialogContext == null || _dismissScheduled) return;
+ _dismissScheduled = true;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _dismissScheduled = false;
+ final route = ModalRoute.of(dialogContext);
+ if (dialogContext.mounted && route != null && route.isActive) {
+ Navigator.of(dialogContext).removeRoute(route);
+ }
+ });
+ }
+
+ @override
+ void didUpdateWidget(ShopInBitPaymentMethodList oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (_dialogContext != null && !_openPaymentIsCurrent) {
+ _dismissPaymentDetails();
+ }
+ }
+
+ @override
+ void dispose() {
+ _dismissPaymentDetails();
+ super.dispose();
+ }
+
+ String? _parseAmount(String paymentUri) {
+ final parsed = AddressUtils.parsePaymentUri(paymentUri);
+ String? amount = parsed?.amount;
+ if (amount == null || amount.isEmpty) {
+ amount = Uri.tryParse(paymentUri)?.queryParameters["amount"];
+ }
+ return amount == null || amount.isEmpty ? null : amount;
+ }
+
+ Future<void> _showPaymentDetails(
+ BuildContext context,
+ int index,
+ String ticker,
+ String address,
+ ) async {
+ _openIndex = index;
+ _openTicker = ticker;
+ _openAddress = address;
+ try {
+ await showDialog<void>(
+ context: context,
+ useRootNavigator: true,
+ builder: (ctx) {
+ _dialogContext = ctx;
+ return _ExternalPaymentDialog(
+ ticker: ticker,
+ address: address,
+ onCheckForPayment: () {
+ final isCurrent = _openPaymentIsCurrent;
+ Navigator.of(ctx).pop();
+ if (isCurrent) {
+ widget.onCheckForPayment(index);
+ }
+ },
+ );
+ },
+ );
+ } finally {
+ _openIndex = null;
+ _openTicker = null;
+ _openAddress = null;
+ _dialogContext = null;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final methods = widget.methods;
+ final addresses = widget.addresses;
+ final enabled = widget.enabled;
+ final count = methods.length < addresses.length
+ ? methods.length
+ : addresses.length;
+ if (count == 0) {
+ return Padding(
+ padding: const EdgeInsets.all(32),
+ child: Text(
+ "No payment address available",
+ textAlign: TextAlign.center,
+ style: STextStyles.itemSubtitle(context),
+ ),
+ );
+ }
+
+ final wallets = ref.watch(pWallets);
+ final rows = <Widget>[];
+
+ for (var i = 0; i < count; i++) {
+ final ticker = methods[i].toUpperCase();
+ final address = addresses[i];
+ final coin = AppConfig.getCryptoCurrencyForTicker(ticker);
+ final hasAddress = address.isNotEmpty;
+ final hasWallet = hasShopInBitWalletForTicker(
+ wallets: wallets,
+ ticker: ticker,
+ paymentUri: address,
+ );
+ final canPayNow = hasWallet && hasAddress;
+ final amount = hasAddress ? _parseAmount(address) : null;
+
+ if (i > 0) {
+ rows.add(const SizedBox(height: 8));
+ }
+
+ rows.add(
+ RoundedWhiteContainer(
+ child: Opacity(
+ opacity: enabled && canPayNow ? 1 : 0.5,
+ child: InkWell(
+ onTap: !enabled || !hasAddress
+ ? null
+ : hasWallet
+ ? () => widget.onPayFromWallet(i)
+ : () => unawaited(
+ _showPaymentDetails(context, i, ticker, address),
+ ),
+ child: Row(
+ children: [
+ if (coin != null)
+ SvgPicture.file(
+ File(ref.watch(coinIconProvider(coin))),
+ width: 24,
+ height: 24,
+ )
+ else
+ SizedBox(
+ width: 24,
+ height: 24,
+ child: Center(
+ child: Text(
+ ticker.substring(
+ 0,
+ ticker.length > 2 ? 2 : ticker.length,
+ ),
+ style: STextStyles.itemSubtitle12(context),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(ticker, style: STextStyles.titleBold12(context)),
+ if (amount != null)
+ Text(
+ "$amount $ticker",
+ style: STextStyles.itemSubtitle12(context),
+ ),
+ ],
+ ),
+ ),
+ if (canPayNow)
+ Text("PAY NOW", style: STextStyles.link2(context))
+ else
+ SvgPicture.asset(
+ Assets.svg.circleInfo,
+ width: 18,
+ height: 18,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle2,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: rows,
+ );
+ }
+}
+
+class _ExternalPaymentDialog extends StatelessWidget {
+ const _ExternalPaymentDialog({
+ required this.ticker,
+ required this.address,
+ required this.onCheckForPayment,
+ });
+
+ final String ticker;
+ final String address;
+ final VoidCallback onCheckForPayment;
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+ final showUsdtWarning =
+ ticker == "USDT" && !isShopInBitEthereumUsdtUri(address);
+
+ final content = Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Center(
+ child: QR(data: address, size: isDesktop ? 200 : 180),
+ ),
+ if (showUsdtWarning) SizedBox(height: isDesktop ? 24 : 16),
+ if (showUsdtWarning)
+ RoundedContainer(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.warningBackground,
+ child: Center(
+ child: Text(
+ "IMPORTANT: Only send USDT (TRC20) to this address, not TRX",
+ style: (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.warningForeground,
+ )),
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ GestureDetector(
+ onTap: () async {
+ await Clipboard.setData(ClipboardData(text: address));
+ if (!context.mounted) return;
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "Copied to clipboard",
+ iconAsset: Assets.svg.copy,
+ context: context,
+ ),
+ );
+ },
+ child: RoundedWhiteContainer(
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Text(
+ "$ticker address",
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context),
+ ),
+ const Spacer(),
+ CopyIcon(
+ width: isDesktop ? 15 : 10,
+ height: isDesktop ? 15 : 10,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.infoItemIcons,
+ ),
+ const SizedBox(width: 4),
+ Text("Copy", style: STextStyles.link2(context)),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ address,
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ PrimaryButton(label: "CHECK FOR PAYMENT", onPressed: onCheckForPayment),
+ ],
+ );
+
+ if (!isDesktop) {
+ return SimpleMobileDialog(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text("$ticker Payment", style: STextStyles.pageTitleH2(context)),
+ const SizedBox(height: 16),
+ content,
+ ],
+ ),
+ );
+ }
+
+ return SDialog(
+ child: SizedBox(
+ width: 480,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "$ticker Payment",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Flexible(
+ child: SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(32, 8, 32, 32),
+ child: content,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart
index bed7001..50ed7f4 100644
--- a/lib/pages/shopinbit/shopinbit_payment_shared.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart
@@ -34,14 +34,11 @@ class ShopInBitPaymentTarget {
}
// 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).
+// address and optional Amount.
ShopInBitPaymentTarget parseShopInBitPaymentTarget({
required String paymentUri,
required String ticker,
CryptoCurrency? coin,
- String? amountFallback,
}) {
String address = "";
final parsed = AddressUtils.parsePaymentUri(paymentUri);
@@ -66,10 +63,6 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({
amountStr = uri.queryParameters['amount'];
}
}
- if (amountStr == null || amountStr.isEmpty) {
- amountStr = amountFallback;
- }
-
final int fractionDigits;
if (coin != null) {
fractionDigits = coin.fractionDigits;
@@ -105,9 +98,12 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({
// in-app and the user has to pay externally.
final RegExp _kEthAddressRegExp = RegExp(r'^0x[0-9a-fA-F]{40}$');
-bool _isEthereumUsdtUri(String paymentUri) {
+bool isShopInBitEthereumUsdtUri(String paymentUri) {
final trimmed = paymentUri.trim();
- if (trimmed.toLowerCase().startsWith('ethereum:')) return true;
+ final uri = Uri.tryParse(trimmed);
+ if (uri != null && uri.scheme.toLowerCase() == 'ethereum') {
+ return _kEthAddressRegExp.hasMatch(uri.path);
+ }
return _kEthAddressRegExp.hasMatch(trimmed);
}
@@ -120,7 +116,7 @@ bool hasShopInBitWalletForTicker({
required String paymentUri,
}) {
if (ticker == "USDT") {
- if (!_isEthereumUsdtUri(paymentUri)) return false;
+ if (!isShopInBitEthereumUsdtUri(paymentUri)) return false;
return wallets.wallets.any(
(w) =>
w.info.coin is Ethereum &&
@@ -149,6 +145,7 @@ Future<void> _pushShopInBitSendFrom({
// instead of returning to the payment view.
await showDialog<void>(
context: context,
+ routeSettings: const RouteSettings(name: ShopInBitSendFromView.routeName),
builder: (_) => ShopInBitSendFromView(
coin: coin,
amount: amount,
@@ -156,6 +153,7 @@ Future<void> _pushShopInBitSendFrom({
apiTicketId: apiTicketId,
shouldPopRoot: true,
tokenContract: tokenContract,
+ routeOnSuccessName: routeOnSuccessName,
),
);
} else {
@@ -203,7 +201,7 @@ Future<bool> tryNavigateToShopInBitWalletSend({
}
if (ticker == "USDT") {
- if (!_isEthereumUsdtUri(paymentUri)) return false;
+ if (!isShopInBitEthereumUsdtUri(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 f7e17eb..8ee0af3 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -1,8 +1,6 @@
import 'dart:async';
-import 'dart:io';
import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
@@ -12,9 +10,7 @@ import '../../providers/global/shopin_bit_service_provider.dart';
import '../../providers/providers.dart';
import '../../services/shopinbit/src/client.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/assets.dart';
import '../../utilities/logger.dart';
import '../../utilities/show_loading.dart';
@@ -24,14 +20,10 @@ 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/dialogs/s_dialog.dart';
-import '../../widgets/dialogs/simple_mobile_dialog.dart';
-import '../../widgets/icon_widgets/copy_icon.dart';
-import '../../widgets/qr.dart';
-import '../../widgets/rounded_container.dart';
import '../../widgets/rounded_white_container.dart';
import '../../widgets/stack_dialog.dart';
import '../home_view/home_view.dart';
+import 'shopinbit_payment_method_list.dart';
import 'shopinbit_payment_shared.dart';
import 'shopinbit_ticket_detail.dart';
import 'shopinbit_tickets_view.dart';
@@ -59,6 +51,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
with WidgetsBindingObserver {
int _selectedMethod = 0;
Timer? _pollTimer;
+ int _paymentRequestId = 0;
static const Duration _kBasePollInterval = Duration(seconds: 15);
static const Duration _kMaxPollInterval = Duration(seconds: 120);
@@ -129,6 +122,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
if (!_isTerminal) _startPolling();
} else {
_pollTimer?.cancel();
+ _paymentRequestId++;
}
}
@@ -143,6 +137,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
void _startPolling() {
_pollTimer?.cancel();
+ _paymentRequestId++;
_pollInterval = _kBasePollInterval;
_scheduleNextPoll();
}
@@ -153,17 +148,21 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
}
Future<void> _pollPayment() async {
+ final requestId = ++_paymentRequestId;
bool ok = false;
try {
+ final customerKey = await _customerKey;
+ if (!mounted || requestId != _paymentRequestId) return;
+
final resp = await ref
.read(pShopinBitService)
.client
- .getPayment(widget.apiTicketId, customerKey: await _customerKey);
+ .getPayment(widget.apiTicketId, customerKey: customerKey);
+ if (!mounted || requestId != _paymentRequestId) return;
+
if (!resp.hasError && resp.value != null) {
ok = true;
- if (mounted) {
- setState(() => _applyPaymentInfo(resp.value!));
- }
+ setState(() => _applyPaymentInfo(resp.value!));
}
} catch (e, s) {
Logging.instance.w(
@@ -172,7 +171,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
stackTrace: s,
);
}
- if (!mounted) return;
+ if (!mounted || requestId != _paymentRequestId) return;
if (_isTerminal) {
_pollTimer?.cancel();
return;
@@ -187,9 +186,10 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
Future<void> _refreshInvoice() async {
_pollTimer?.cancel();
+ final requestId = ++_paymentRequestId;
final customerKey = await _customerKey;
- if (!mounted) return;
+ if (!mounted || requestId != _paymentRequestId) return;
final resp = await showLoading(
whileFuture: ref
@@ -204,7 +204,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
message: "Refreshing invoice",
rootNavigator: true,
);
- if (!mounted) return;
+ if (!mounted || requestId != _paymentRequestId) return;
if (resp != null && !resp.hasError && resp.value != null) {
setState(() => _applyPaymentInfo(resp.value!));
}
@@ -213,9 +213,10 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
Future<void> _checkForPayment() async {
_pollTimer?.cancel();
+ final requestId = ++_paymentRequestId;
final customerKey = await _customerKey;
- if (!mounted) return;
+ if (!mounted || requestId != _paymentRequestId) return;
final resp = await showLoading(
whileFuture: ref
@@ -226,7 +227,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
message: "Checking for payment",
rootNavigator: true,
);
- if (!mounted) return;
+ if (!mounted || requestId != _paymentRequestId) return;
if (resp != null && !resp.hasError && resp.value != null) {
setState(() => _applyPaymentInfo(resp.value!));
@@ -272,7 +273,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
desktopPopRootNavigator: Util.isDesktop,
),
);
- if (!mounted) return;
+ if (!mounted || requestId != _paymentRequestId) return;
}
if (!_isTerminal) {
@@ -282,6 +283,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
Future<void> _confirmPayment() async {
_pollTimer?.cancel();
+ _paymentRequestId++;
final method = _methods[_selectedMethod];
final ticker = method.toUpperCase();
@@ -289,10 +291,9 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
paymentUri: _currentAddress,
ticker: ticker,
coin: AppConfig.getCryptoCurrencyForTicker(ticker),
- amountFallback: _paymentInfo?.due,
);
- if (await tryNavigateToShopInBitWalletSend(
+ final navigated = await tryNavigateToShopInBitWalletSend(
ref: ref,
context: context,
ticker: ticker,
@@ -300,10 +301,10 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
address: target.address,
amount: target.amount,
apiTicketId: widget.apiTicketId,
- )) {
- return;
- }
+ );
if (!mounted) return;
+ if (!_isTerminal) _startPolling();
+ if (navigated) return;
// Couldn't launch the in-wallet send.
unawaited(
@@ -315,9 +316,6 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
context: context,
),
);
- if (!_isTerminal) {
- _startPolling();
- }
}
void _popToTickets() {
@@ -364,18 +362,6 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
}
}
- String? _parseBip21Amount(String bip21Uri) {
- final parsed = AddressUtils.parsePaymentUri(bip21Uri);
- String? amountStr = parsed?.amount;
- if (amountStr == null || amountStr.isEmpty) {
- final uri = Uri.tryParse(bip21Uri);
- if (uri != null) {
- amountStr = uri.queryParameters['amount'];
- }
- }
- return (amountStr != null && amountStr.isNotEmpty) ? amountStr : null;
- }
-
void _onOwnedCoinTap(int methodIndex) {
if (!_payNowEnabled) return;
if (_addresses[methodIndex].isEmpty) return;
@@ -383,114 +369,10 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
unawaited(_confirmPayment());
}
- void _onUnownedCoinTap(int methodIndex) {
- if (!_payNowEnabled) return;
- final ticker = _methods[methodIndex].toUpperCase();
- final address = _addresses[methodIndex];
- if (address.isEmpty) return;
-
- showDialog<void>(
- context: context,
- useRootNavigator: true,
- builder: (ctx) => _UnownedCoinPaymentDialog(
- ticker: ticker,
- address: address,
- onCheckForPayment: () {
- Navigator.of(ctx).pop();
- _checkForPayment();
- },
- ),
- );
- }
-
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
- final wallets = ref.watch(pWallets);
- // Build coin rows from _methods/_addresses
- final coinRows = <Widget>[];
- 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 canPayNow = hasWallet && hasAddress;
- final amountStr = hasAddress ? _parseBip21Amount(_addresses[i]) : null;
-
- if (i > 0) {
- coinRows.add(const SizedBox(height: 8));
- }
-
- coinRows.add(
- RoundedWhiteContainer(
- child: Opacity(
- opacity: canPayNow ? 1.0 : 0.5,
- child: InkWell(
- onTap: !hasAddress
- ? null
- : (hasWallet
- ? () => _onOwnedCoinTap(i)
- : () => _onUnownedCoinTap(i)),
- child: Row(
- children: [
- if (coin != null)
- SvgPicture.file(
- File(ref.watch(coinIconProvider(coin))),
- width: 24,
- height: 24,
- )
- else
- SizedBox(
- width: 24,
- height: 24,
- child: Center(
- child: Text(
- ticker.substring(
- 0,
- ticker.length > 2 ? 2 : ticker.length,
- ),
- style: STextStyles.itemSubtitle12(context),
- ),
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(ticker, style: STextStyles.titleBold12(context)),
- if (amountStr != null)
- Text(
- "$amountStr $ticker",
- style: STextStyles.itemSubtitle12(context),
- ),
- ],
- ),
- ),
- if (canPayNow)
- Text("PAY NOW", style: STextStyles.link2(context))
- else
- SvgPicture.asset(
- Assets.svg.circleInfo,
- width: 18,
- height: 18,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle2,
- ),
- ],
- ),
- ),
- ),
- ),
- );
- }
-
final content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -679,8 +561,14 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
),
],
SizedBox(height: isDesktop ? 24 : 16),
- // Coin list (replaces tab selector + QR + address + global button)
- if (!_isExpiredOrInvalid && !_isNoPaymentRequired) ...coinRows,
+ if (!_isExpiredOrInvalid && !_isNoPaymentRequired)
+ ShopInBitPaymentMethodList(
+ methods: _methods,
+ addresses: _addresses,
+ enabled: _payNowEnabled,
+ onPayFromWallet: _onOwnedCoinTap,
+ onCheckForPayment: (_) => unawaited(_checkForPayment()),
+ ),
],
);
@@ -723,149 +611,3 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
);
}
}
-
-class _UnownedCoinPaymentDialog extends StatelessWidget {
- const _UnownedCoinPaymentDialog({
- required this.ticker,
- required this.address,
- required this.onCheckForPayment,
- });
-
- final String ticker;
- final String address;
- final VoidCallback onCheckForPayment;
-
- @override
- Widget build(BuildContext context) {
- final isDesktop = Util.isDesktop;
-
- final content = Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Center(
- child: QR(data: address, size: isDesktop ? 200 : 180),
- ),
- if (ticker == "USDT") SizedBox(height: isDesktop ? 24 : 16),
- if (ticker == "USDT")
- RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningBackground,
- child: Center(
- child: Text(
- "IMPORTANT: Only send USDT (TRX20) to this address, not TRX",
- style: (isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningForeground,
- )),
- ),
- ),
- ),
- const SizedBox(height: 16),
- GestureDetector(
- onTap: () async {
- await Clipboard.setData(ClipboardData(text: address));
- if (!context.mounted) return;
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.info,
- message: "Copied to clipboard",
- iconAsset: Assets.svg.copy,
- context: context,
- ),
- );
- },
- child: RoundedWhiteContainer(
- child: Column(
- children: [
- Row(
- children: [
- Text(
- "$ticker address",
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context),
- ),
- const Spacer(),
- CopyIcon(
- width: isDesktop ? 15 : 10,
- height: isDesktop ? 15 : 10,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.infoItemIcons,
- ),
- const SizedBox(width: 4),
- Text("Copy", style: STextStyles.link2(context)),
- ],
- ),
- const SizedBox(height: 4),
- Row(
- children: [
- Expanded(
- child: Text(
- address,
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context),
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- const SizedBox(height: 16),
- PrimaryButton(label: "CHECK FOR PAYMENT", onPressed: onCheckForPayment),
- ],
- );
-
- if (!isDesktop) {
- return SimpleMobileDialog(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text("$ticker Payment", style: STextStyles.pageTitleH2(context)),
- const SizedBox(height: 16),
- content,
- ],
- ),
- );
- }
-
- return SDialog(
- child: SizedBox(
- width: 480,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Padding(
- padding: const EdgeInsets.only(left: 32),
- child: Text(
- "$ticker Payment",
- style: STextStyles.desktopH3(context),
- ),
- ),
- const DesktopDialogCloseButton(),
- ],
- ),
- Flexible(
- child: SingleChildScrollView(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(32, 8, 32, 32),
- child: content,
- ),
- ),
- ),
- ],
- ),
- ),
- );
- }
-}
diff --git a/lib/pages/shopinbit/shopinbit_send_from_view.dart b/lib/pages/shopinbit/shopinbit_send_from_view.dart
index 3e6bbef..fb54b1f 100644
--- a/lib/pages/shopinbit/shopinbit_send_from_view.dart
+++ b/lib/pages/shopinbit/shopinbit_send_from_view.dart
@@ -381,6 +381,10 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
: HomeView.routeName),
apiTicketId: apiTicketId,
tokenContract: tokenContract,
+ popThroughRouteName:
+ Util.isDesktop && widget.routeOnSuccessName != null
+ ? ShopInBitSendFromView.routeName
+ : null,
),
settings: const RouteSettings(
name: ShopInBitConfirmSendView.routeName,
Why this scored 24/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.