feat(shopinbit): add ShopInBit payment and send-from views
What changed, and why it matters
This commit adds two new screens that let a user pay a ShopInBit invoice from inside the Stack Wallet app. It fetches payment details from ShopInBit, shows a QR code and address, and can start a wallet-to-address send for BTC or XMR. There is no obvious malicious code, but the new code trusts a third-party API and URIs/addresses supplied by it, and contains a small hard-coded test fallback that could send real money if the sandbox flag were misused.
Treat this as a feature commit that should undergo normal security review of the third-party integration. Verify that ShopInBit API responses are authenticated/validated, that URI parsing cannot be tricked into extracting an attacker-controlled address or amount, that the sandbox fallback amount cannot be reached in production, and that swallowed exceptions do not hide payment failures. No immediate incident response is indicated by the diff alone.
Security signals we found
New third-party integration (ShopInBit API) added to wallet payment flow
Payment address and amount derived from external API response and URI parsing
Network errors silently swallowed in `_pollPayment`, `_loadPayment`, `_refreshInvoice`, and amount parsing
Hard-coded fallback amount (10000 raw units) used when `ShopInBitService.instance.client.sandbox` is true
Direct mutation of `widget.model` state
No visible input validation on API-derived address/amount before passing to wallet prepareSend
Evidence from the diff
The patch introduces ShopInBitPaymentView and ShopInBitSendFromView plus route wiring. PaymentView polls ShopInBit’s getPayment endpoint every 15 seconds, derives payment methods/addresses from paymentLinks, parses BIP21/Monero URIs with AddressUtils.parsePaymentUri, and launches the wallet send flow for BTC/XMR. SendFromView lists matching wallets, calls prepareSend with the extracted address and amount, then pushes a confirmation screen. Notable behaviors: API responses and payment URIs are used without visible validation beyond URI parsing; a sandbox-only fallback hard-codes a 10000-satoshi/unit amount if the URI omits an amount; errors are swallowed (catch (_) {}) in several network paths; and widget.model is mutated directly (widget.model.status = ...).
Changed components
lib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_send_from_view.dartlib/route_generator.dartShopInBit integration / payment flowBTC and XMR send preparation pathsInspect captured patch +1137 / −0
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
new file mode 100644
index 0000000..f8db2a0
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -0,0 +1,667 @@
+import 'dart:async';
+
+import 'package:decimal/decimal.dart';
+import 'package:flutter/gestures.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 'package:url_launcher/url_launcher.dart';
+
+import '../../app_config.dart';
+import '../../models/shopinbit/shopinbit_order_model.dart';
+import '../../notifications/show_flush_bar.dart';
+import '../../route_generator.dart';
+import '../../services/shopinbit/shopinbit_service.dart';
+import '../../services/shopinbit/src/models/payment.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 '../../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/qr.dart';
+import '../../widgets/rounded_white_container.dart';
+import 'shopinbit_send_from_view.dart';
+
+class ShopInBitPaymentView extends ConsumerStatefulWidget {
+ const ShopInBitPaymentView({super.key, required this.model});
+
+ static const String routeName = "/shopInBitPayment";
+
+ final ShopInBitOrderModel model;
+
+ @override
+ ConsumerState<ShopInBitPaymentView> createState() =>
+ _ShopInBitPaymentViewState();
+}
+
+class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
+ bool _termsAccepted = false;
+ bool _loading = false;
+ int _selectedMethod = 0;
+ Timer? _pollTimer;
+
+ PaymentInfo? _paymentInfo;
+
+ // Derived from API payment_links keys, fallback to defaults
+ List<String> _methods = ["BTC", "XMR", "USDT"];
+ List<String> _addresses = ["", "", ""];
+
+ String get _currentAddress =>
+ _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : "";
+
+ String get _totalPrice =>
+ _paymentInfo?.customerPrice ?? widget.model.offerPrice ?? "0";
+
+ String get _status => _paymentInfo?.status ?? 'ready_to_pay';
+
+ bool get _isExpiredOrInvalid => _status == 'expired' || _status == 'invalid';
+
+ bool get _isTerminal => const {
+ 'paid',
+ 'paid_over',
+ 'paid_late',
+ 'payment_processing',
+ }.contains(_status);
+
+ bool get _payNowEnabled =>
+ _termsAccepted && !_isExpiredOrInvalid && !_isTerminal;
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.model.apiTicketId != 0) {
+ _loadPayment();
+ }
+ }
+
+ @override
+ void dispose() {
+ _pollTimer?.cancel();
+ super.dispose();
+ }
+
+ void _applyPaymentInfo(PaymentInfo info) {
+ _paymentInfo = info;
+ final links = info.paymentLinks;
+ if (links.isNotEmpty) {
+ _methods = links.keys.map((k) => k.toUpperCase()).toList();
+ _addresses = links.values.toList();
+ }
+ }
+
+ void _startPolling() {
+ _pollTimer?.cancel();
+ _pollTimer = Timer.periodic(
+ const Duration(seconds: 15),
+ (_) => _pollPayment(),
+ );
+ }
+
+ Future<void> _pollPayment() async {
+ try {
+ final resp = await ShopInBitService.instance.client.getPayment(
+ widget.model.apiTicketId,
+ );
+ if (!resp.hasError && resp.value != null && mounted) {
+ setState(() => _applyPaymentInfo(resp.value!));
+ if (_isTerminal) {
+ _pollTimer?.cancel();
+ }
+ }
+ } catch (_) {}
+ }
+
+ Future<void> _loadPayment() async {
+ setState(() => _loading = true);
+ try {
+ final resp = await ShopInBitService.instance.client.getPayment(
+ widget.model.apiTicketId,
+ );
+ if (!resp.hasError && resp.value != null) {
+ _applyPaymentInfo(resp.value!);
+ }
+ } catch (_) {
+ // Fall back to local/dummy data
+ } finally {
+ if (mounted) {
+ setState(() => _loading = false);
+ _startPolling();
+ }
+ }
+ }
+
+ Future<void> _refreshInvoice() async {
+ setState(() => _loading = true);
+ try {
+ final resp = await ShopInBitService.instance.client.getPayment(
+ widget.model.apiTicketId,
+ retry: true,
+ );
+ if (!resp.hasError && resp.value != null) {
+ _applyPaymentInfo(resp.value!);
+ }
+ } catch (_) {}
+ if (mounted) {
+ setState(() => _loading = false);
+ _startPolling();
+ }
+ }
+
+ Future<void> _openTerms() async {
+ const url = "https://api.shopinbit.com/static/policy/terms.html";
+ await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
+ }
+
+ void _confirmPayment() {
+ _pollTimer?.cancel();
+ final method = _methods[_selectedMethod];
+ final ticker = method.toUpperCase();
+
+ // Only BTC and XMR have Stack Wallet coin classes for sending
+ final coin = (ticker == "BTC" || ticker == "XMR")
+ ? AppConfig.getCryptoCurrencyForTicker(ticker)
+ : null;
+
+ if (coin != null && _currentAddress.isNotEmpty) {
+ // Try to parse BIP21/Monero URI for address + amount
+ final parsed = AddressUtils.parsePaymentUri(_currentAddress);
+
+ // Extract address: from parsed URI, or strip scheme manually
+ String address;
+ if (parsed?.address != null && parsed!.address.isNotEmpty) {
+ address = parsed.address;
+ } else {
+ // Fallback: strip URI scheme prefix if present
+ 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;
+ }
+ }
+
+ // Try amount from: 1) parsed URI, 2) raw query param, 3) due
+ String? amountStr = parsed?.amount;
+ if (amountStr == null || amountStr.isEmpty) {
+ // Try extracting from raw URI query string
+ final uri = Uri.tryParse(_currentAddress);
+ if (uri != null) {
+ amountStr = uri.queryParameters['amount'];
+ }
+ }
+ if (amountStr == null || amountStr.isEmpty) {
+ amountStr = _paymentInfo?.due;
+ }
+
+ Amount? amount;
+ if (amountStr != null && amountStr.isNotEmpty) {
+ try {
+ amount = Amount.fromDecimal(
+ Decimal.parse(amountStr),
+ fractionDigits: coin.fractionDigits,
+ );
+ } catch (_) {
+ // amount stays null
+ }
+ }
+
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ unawaited(
+ showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitSendFromView(
+ coin: coin,
+ amount: amount,
+ address: address,
+ model: widget.model,
+ shouldPopRoot: true,
+ ),
+ ),
+ );
+ } else {
+ Navigator.of(context).push(
+ RouteGenerator.getRoute<dynamic>(
+ shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
+ builder: (_) => ShopInBitSendFromView(
+ coin: coin,
+ amount: amount,
+ address: address,
+ model: widget.model,
+ ),
+ settings: const RouteSettings(
+ name: ShopInBitSendFromView.routeName,
+ ),
+ ),
+ );
+ }
+ return;
+ }
+
+ // USDT or other unsupported coins: keep existing behavior
+ 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);
+ }
+ }
+
+ void _copyAddress(BuildContext context) {
+ Clipboard.setData(ClipboardData(text: _currentAddress));
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "Copied to clipboard",
+ iconAsset: Assets.svg.copy,
+ context: context,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+
+ const loadingOverlay = Center(
+ child: SizedBox(
+ width: 24,
+ height: 24,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ );
+
+ final methodSelector = 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,
+ children: [
+ Text(
+ "Payment",
+ style: isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ RoundedWhiteContainer(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Total",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ Text(
+ "$_totalPrice EUR",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ ],
+ ),
+ ),
+ // Status banner
+ if (_status == 'underpaid') ...[
+ SizedBox(height: isDesktop ? 16 : 8),
+ RoundedWhiteContainer(
+ child: Row(
+ children: [
+ SvgPicture.asset(
+ Assets.svg.alertCircle,
+ width: 20,
+ height: 20,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorOrange,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ "Payment underpaid. Remaining: "
+ "${_paymentInfo?.due ?? '?'} EUR. "
+ "Please send the remaining amount.",
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ )
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorOrange,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ if (_isExpiredOrInvalid) ...[
+ SizedBox(height: isDesktop ? 16 : 8),
+ RoundedWhiteContainer(
+ child: Column(
+ children: [
+ Row(
+ children: [
+ SvgPicture.asset(
+ Assets.svg.alertCircle,
+ width: 20,
+ height: 20,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorRed,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ "Invoice expired.",
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ )
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorRed,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ SecondaryButton(
+ label: "Refresh Invoice",
+ onPressed: _refreshInvoice,
+ ),
+ ],
+ ),
+ ),
+ ],
+ if (_isTerminal) ...[
+ SizedBox(height: isDesktop ? 16 : 8),
+ RoundedWhiteContainer(
+ child: Row(
+ children: [
+ SvgPicture.asset(
+ Assets.svg.checkCircle,
+ width: 20,
+ height: 20,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorGreen,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ "Payment received.",
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ )
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorGreen,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ SizedBox(height: isDesktop ? 24 : 16),
+ if (!_isExpiredOrInvalid) ...[
+ methodSelector,
+ SizedBox(height: isDesktop ? 24 : 16),
+ if (_currentAddress.isNotEmpty)
+ Center(
+ child: QR(data: _currentAddress, size: isDesktop ? 200 : 180),
+ ),
+ if (_currentAddress.isEmpty)
+ Center(
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: Text(
+ "No payment address available",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ ),
+ ),
+ 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(),
+ Icon(
+ Icons.copy,
+ size: 14,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorBlue,
+ ),
+ 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),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ SizedBox(height: isDesktop ? 16 : 12),
+ GestureDetector(
+ onTap: () {
+ setState(() {
+ _termsAccepted = !_termsAccepted;
+ });
+ },
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: 20,
+ height: 26,
+ child: IgnorePointer(
+ child: Checkbox(
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ value: _termsAccepted,
+ onChanged: (_) {},
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: RichText(
+ text: TextSpan(
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.w500_14(context),
+ children: [
+ const TextSpan(text: "I accept the "),
+ TextSpan(
+ text: "Terms & Conditions",
+ style: STextStyles.richLink(
+ context,
+ ).copyWith(fontSize: isDesktop ? null : 14),
+ recognizer: TapGestureRecognizer()
+ ..onTap = _openTerms,
+ ),
+ const TextSpan(text: "."),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ PrimaryButton(
+ label: "PAY NOW",
+ enabled: _payNowEnabled,
+ onPressed: _payNowEnabled ? _confirmPayment : null,
+ ),
+ ],
+ );
+
+ if (isDesktop) {
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 750,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "ShopInBit",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 8,
+ ),
+ child: Stack(
+ children: [
+ SingleChildScrollView(child: content),
+ if (_loading) loadingOverlay,
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ 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) loadingOverlay,
+ ],
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/shopinbit_send_from_view.dart b/lib/pages/shopinbit/shopinbit_send_from_view.dart
new file mode 100644
index 0000000..091dc6a
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_send_from_view.dart
@@ -0,0 +1,442 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/svg.dart';
+
+import '../../app_config.dart';
+import '../../models/shopinbit/shopinbit_order_model.dart';
+import '../../providers/providers.dart';
+import '../../route_generator.dart';
+import '../../themes/coin_icon_provider.dart';
+import '../../themes/stack_colors.dart';
+import '../../themes/theme_providers.dart';
+import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_formatter.dart';
+import '../../utilities/constants.dart';
+import '../../utilities/enums/fee_rate_type_enum.dart';
+import '../../utilities/logger.dart';
+import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
+import '../../wallets/crypto_currency/crypto_currency.dart';
+import '../../wallets/isar/providers/wallet_info_provider.dart';
+import '../../wallets/models/tx_data.dart';
+import '../../services/shopinbit/shopinbit_service.dart';
+import '../../wallets/wallet/intermediate/external_wallet.dart';
+import '../../widgets/background.dart';
+import '../../widgets/conditional_parent.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/rounded_white_container.dart';
+import '../../widgets/stack_dialog.dart';
+import '../home_view/home_view.dart';
+import '../send_view/sub_widgets/building_transaction_dialog.dart';
+import 'shopinbit_confirm_send_view.dart';
+
+class ShopInBitSendFromView extends ConsumerStatefulWidget {
+ const ShopInBitSendFromView({
+ super.key,
+ required this.coin,
+ required this.model,
+ this.amount,
+ required this.address,
+ this.shouldPopRoot = false,
+ });
+
+ static const String routeName = "/shopInBitSendFrom";
+
+ final CryptoCurrency coin;
+ final Amount? amount;
+ final String address;
+ final ShopInBitOrderModel model;
+ final bool shouldPopRoot;
+
+ @override
+ ConsumerState<ShopInBitSendFromView> createState() =>
+ _ShopInBitSendFromViewState();
+}
+
+class _ShopInBitSendFromViewState extends ConsumerState<ShopInBitSendFromView> {
+ late final CryptoCurrency coin;
+ late final Amount? amount;
+ late final String address;
+ late final ShopInBitOrderModel model;
+
+ @override
+ void initState() {
+ coin = widget.coin;
+ address = widget.address;
+ amount = widget.amount;
+ model = widget.model;
+ super.initState();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final walletIds = ref
+ .watch(pWallets)
+ .wallets
+ .where((e) => e.info.coin == coin)
+ .map((e) => e.walletId)
+ .toList();
+
+ final isDesktop = Util.isDesktop;
+
+ return ConditionalParent(
+ condition: !isDesktop,
+ builder: (child) {
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ ),
+ title: Text("Send from", style: STextStyles.navBarTitle(context)),
+ ),
+ body: SafeArea(
+ child: Padding(padding: const EdgeInsets.all(16), child: child),
+ ),
+ ),
+ );
+ },
+ child: ConditionalParent(
+ condition: isDesktop,
+ builder: (child) => DesktopDialog(
+ maxHeight: double.infinity,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "Send from ${AppConfig.prefix}",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ DesktopDialogCloseButton(
+ onPressedOverride: Navigator.of(
+ context,
+ rootNavigator: widget.shouldPopRoot,
+ ).pop,
+ ),
+ ],
+ ),
+ Padding(
+ padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32),
+ child: child,
+ ),
+ ],
+ ),
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ amount != null
+ ? "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount!)}"
+ : "Select a wallet to pay",
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ ConditionalParent(
+ condition: !isDesktop,
+ builder: (child) => Expanded(child: child),
+ child: ListView.builder(
+ primary: isDesktop ? false : null,
+ shrinkWrap: isDesktop,
+ itemCount: walletIds.length,
+ itemBuilder: (context, index) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: ShopInBitSendFromCard(
+ walletId: walletIds[index],
+ amount: amount,
+ address: address,
+ model: model,
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class ShopInBitSendFromCard extends ConsumerStatefulWidget {
+ const ShopInBitSendFromCard({
+ super.key,
+ required this.walletId,
+ this.amount,
+ required this.address,
+ required this.model,
+ });
+
+ final String walletId;
+ final Amount? amount;
+ final String address;
+ final ShopInBitOrderModel model;
+
+ @override
+ ConsumerState<ShopInBitSendFromCard> createState() =>
+ _ShopInBitSendFromCardState();
+}
+
+class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
+ late final String walletId;
+ late final Amount? amount;
+ late final String address;
+ late final ShopInBitOrderModel model;
+
+ Future<void> _send() async {
+ final coin = ref.read(pWalletCoin(walletId));
+
+ Amount? sendAmount = amount;
+ if (sendAmount == null) {
+ if (ShopInBitService.instance.client.sandbox) {
+ // Sandbox URIs omit ?amount=, use a small fallback so prepareSend
+ // can build a real transaction for UI testing.
+ sendAmount = Amount(
+ rawValue: BigInt.from(10000),
+ fractionDigits: coin.fractionDigits,
+ );
+ } else {
+ await showDialog<dynamic>(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: true,
+ builder: (context) {
+ return StackDialog(
+ title: "Transaction failed",
+ message: "Payment amount not available yet",
+ rightButton: TextButton(
+ style: Theme.of(context)
+ .extension<StackColors>()!
+ .getSecondaryEnabledButtonStyle(context),
+ child: Text(
+ "Ok",
+ style: STextStyles.button(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.buttonTextSecondary,
+ ),
+ ),
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ );
+ },
+ );
+ return;
+ }
+ }
+
+ try {
+ bool wasCancelled = false;
+
+ final wallet = ref.read(pWallets).getWallet(walletId);
+
+ unawaited(
+ showDialog<dynamic>(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: false,
+ builder: (context) {
+ return ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => DesktopDialog(
+ maxWidth: 400,
+ maxHeight: double.infinity,
+ child: Padding(padding: const EdgeInsets.all(32), child: child),
+ ),
+ child: BuildingTransactionDialog(
+ coin: coin,
+ isSpark: false,
+ onCancel: () {
+ wasCancelled = true;
+
+ Navigator.of(context).pop();
+ },
+ ),
+ );
+ },
+ ),
+ );
+
+ if (wallet is ExternalWallet) {
+ await wallet.init();
+ await wallet.open();
+ }
+
+ final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
+
+ TxData txData;
+
+ final recipient = TxRecipient(
+ address: address,
+ amount: sendAmount,
+ isChange: false,
+ addressType: wallet.cryptoCurrency.getAddressType(address)!,
+ );
+
+ final txDataFuture = wallet.prepareSend(
+ txData: TxData(
+ recipients: [recipient],
+ feeRateType: FeeRateType.average,
+ ),
+ );
+
+ final results = await Future.wait([txDataFuture, time]);
+
+ txData = results.first as TxData;
+
+ if (!wasCancelled) {
+ if (mounted) {
+ Navigator.of(context, rootNavigator: Util.isDesktop).pop();
+ }
+
+ txData = txData.copyWith(note: "ShopInBit payment");
+
+ if (mounted) {
+ await Navigator.of(context).push(
+ RouteGenerator.getRoute<dynamic>(
+ shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
+ builder: (_) => ShopInBitConfirmSendView(
+ txData: txData,
+ walletId: walletId,
+ routeOnSuccessName: HomeView.routeName,
+ model: model,
+ ),
+ settings: const RouteSettings(
+ name: ShopInBitConfirmSendView.routeName,
+ ),
+ ),
+ );
+ }
+ }
+ } catch (e, s) {
+ Logging.instance.e("$e\n$s", error: e, stackTrace: s);
+ if (mounted) {
+ Navigator.of(context, rootNavigator: Util.isDesktop).pop();
+
+ await showDialog<dynamic>(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: true,
+ builder: (context) {
+ return StackDialog(
+ title: "Transaction failed",
+ message: e.toString(),
+ rightButton: TextButton(
+ style: Theme.of(context)
+ .extension<StackColors>()!
+ .getSecondaryEnabledButtonStyle(context),
+ child: Text(
+ "Ok",
+ style: STextStyles.button(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.buttonTextSecondary,
+ ),
+ ),
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ ),
+ );
+ },
+ );
+ }
+ }
+ }
+
+ @override
+ void initState() {
+ walletId = widget.walletId;
+ amount = widget.amount;
+ address = widget.address;
+ model = widget.model;
+ super.initState();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final coin = ref.watch(pWalletCoin(walletId));
+
+ return RoundedWhiteContainer(
+ padding: const EdgeInsets.all(0),
+ child: MaterialButton(
+ splashColor: Theme.of(context).extension<StackColors>()!.highlight,
+ key: Key("walletsSheetItemButtonKey_$walletId"),
+ padding: const EdgeInsets.all(8),
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ ),
+ onPressed: () async {
+ if (mounted) {
+ unawaited(_send());
+ }
+ },
+ child: Row(
+ children: [
+ Container(
+ decoration: BoxDecoration(
+ color: ref.watch(pCoinColor(coin)).withOpacity(0.5),
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: SvgPicture.file(
+ File(ref.watch(coinIconProvider(coin))),
+ width: 24,
+ height: 24,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ ref.watch(pWalletName(walletId)),
+ style: STextStyles.titleBold12(context),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ ref
+ .watch(pAmountFormatter(coin))
+ .format(ref.watch(pWalletBalance(walletId)).spendable),
+ style: STextStyles.itemSubtitle(context),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index f25879b..c1d72b3 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -90,6 +90,8 @@ import 'pages/shopinbit/shopinbit_step_2.dart';
import 'pages/shopinbit/shopinbit_step_3.dart';
import 'pages/shopinbit/shopinbit_car_fee_view.dart';
import 'pages/shopinbit/shopinbit_offer_view.dart';
+import 'pages/shopinbit/shopinbit_payment_view.dart';
+import 'pages/shopinbit/shopinbit_send_from_view.dart';
import 'pages/shopinbit/shopinbit_shipping_view.dart';
import 'pages/shopinbit/shopinbit_step_4.dart';
import 'pages/shopinbit/shopinbit_ticket_detail.dart';
@@ -1155,6 +1157,32 @@ class RouteGenerator {
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
+ case ShopInBitPaymentView.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => ShopInBitPaymentView(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
+ case ShopInBitSendFromView.routeName:
+ if (args
+ is Tuple4<CryptoCurrency, Amount, String, ShopInBitOrderModel>) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => ShopInBitSendFromView(
+ coin: args.item1,
+ amount: args.item2,
+ address: args.item3,
+ model: args.item4,
+ ),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
case GlobalSettingsView.routeName:
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
Why this scored 20/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.