From 2a8d7e903003df3db7e8978fb65854718dbcd906 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 25 Jun 2026 16:52:04 +0800 Subject: [PATCH 1/9] Fix scan not enabling continue button on recipient screens Scanning an address left the send and multisig-propose recipient buttons stuck on "Enter address". Scan assigned the controller text inside setState, firing the change listener's nested setState mid-update, so validation never enabled the button. Route scan, paste, and recent through a shared _setRecipient helper that assigns the controller text outside setState (the same path paste already used), letting the listener drive validation. Also removes the duplicated assignment across the three input methods in both screens. --- .../propose/propose_recipient_screen.dart | 30 ++++++++----------- .../screens/send/select_recipient_screen.dart | 30 ++++++++----------- 2 files changed, 26 insertions(+), 34 deletions(-) diff --git a/mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart b/mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart index 7d0afaab..19d33f79 100644 --- a/mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart @@ -116,6 +116,15 @@ class _ProposeRecipientScreenState extends ConsumerState return true; } + /// Single entry point for every way a recipient is supplied (scan, paste, + /// recent). The controller text is assigned last and outside [setState] so the + /// [_onRecipientChanged] listener drives validation and the continue button. + void _setRecipient(String address, {String amount = '', bool isPayMode = false}) { + _amountController.text = amount; + setState(() => _isPayMode = isPayMode); + _recipientController.text = address; + } + Future _scanQr() async { final substrate = ref.read(substrateServiceProvider); final scanResult = await Navigator.push( @@ -130,16 +139,9 @@ class _ProposeRecipientScreenState extends ConsumerState if (scanResult == null || !mounted) return; final payment = PaymentIntent.tryParseUrl(scanResult); if (payment != null) { - setState(() { - _recipientController.text = payment.to; - _amountController.text = payment.amount; - _isPayMode = true; - }); + _setRecipient(payment.to, amount: payment.amount, isPayMode: true); } else { - setState(() { - _recipientController.text = scanResult; - _isPayMode = false; - }); + _setRecipient(scanResult); } } @@ -171,19 +173,13 @@ class _ProposeRecipientScreenState extends ConsumerState }); } - void _onRecentTap(String address) { - _amountController.clear(); - setState(() => _isPayMode = false); - _recipientController.text = address; - } + void _onRecentTap(String address) => _setRecipient(address); Future _pasteRecipient() async { final data = await Clipboard.getData(Clipboard.kTextPlain); final text = data?.text?.trim() ?? ''; if (text.isEmpty) return; - _amountController.clear(); - setState(() => _isPayMode = false); - _recipientController.text = text; + _setRecipient(text); } @override diff --git a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart index a3f4643a..4a6b1528 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -121,6 +121,15 @@ class _SelectRecipientScreenState extends ConsumerState { return true; } + /// Single entry point for every way a recipient is supplied (scan, paste, + /// recent). The controller text is assigned last and outside [setState] so the + /// [_onRecipientChanged] listener drives validation and the continue button. + void _setRecipient(String address, {String amount = '', bool isPayMode = false}) { + _amountController.text = amount; + setState(() => _isPayMode = isPayMode); + _recipientController.text = address; + } + Future _scanQr() async { final substrate = ref.read(substrateServiceProvider); final scanResult = await Navigator.push( @@ -135,16 +144,9 @@ class _SelectRecipientScreenState extends ConsumerState { if (scanResult == null || !mounted) return; final payment = PaymentIntent.tryParseUrl(scanResult); if (payment != null) { - setState(() { - _recipientController.text = payment.to; - _amountController.text = payment.amount; - _isPayMode = true; - }); + _setRecipient(payment.to, amount: payment.amount, isPayMode: true); } else { - setState(() { - _recipientController.text = scanResult; - _isPayMode = false; - }); + _setRecipient(scanResult); } } @@ -176,19 +178,13 @@ class _SelectRecipientScreenState extends ConsumerState { }); } - void _onRecentTap(String address) { - _amountController.clear(); - setState(() => _isPayMode = false); - _recipientController.text = address; - } + void _onRecentTap(String address) => _setRecipient(address); Future _pasteRecipient() async { final data = await Clipboard.getData(Clipboard.kTextPlain); final text = data?.text?.trim() ?? ''; if (text.isEmpty) return; - _amountController.clear(); - setState(() => _isPayMode = false); - _recipientController.text = text; + _setRecipient(text); } @override From 260b309e6ce5f99f385e5184c0331aa36199df47 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 25 Jun 2026 19:26:04 +0800 Subject: [PATCH 2/9] refactor send flow align with design, delete duplicated screens from msig --- .../shared_address_action_sheet.dart | 8 +- mobile-app/lib/l10n/app_en.arb | 2 +- mobile-app/lib/l10n/app_localizations.dart | 2 +- mobile-app/lib/l10n/app_localizations_en.dart | 2 +- .../transaction_submission_service.dart | 14 +- mobile-app/lib/shared/utils/url_utils.dart | 5 + .../lib/v2/components/explorer_link.dart | 37 ++ .../activity/transaction_detail_sheet.dart | 27 +- .../lib/v2/screens/home/home_screen.dart | 20 +- .../multisig_proposal_detail_sheet.dart | 34 +- .../propose/propose_amount_screen.dart | 592 ------------------ .../multisig/propose/propose_done_screen.dart | 156 ----- .../propose/propose_recipient_screen.dart | 414 ------------ .../propose/propose_review_screen.dart | 269 -------- .../lib/v2/screens/pos/pos_qr_screen.dart | 26 +- .../v2/screens/send/input_amount_screen.dart | 199 +++--- .../send/keystone_scan_signature_screen.dart | 16 +- .../v2/screens/send/keystone_sign_screen.dart | 4 + .../send/multisig_propose_strategy.dart | 230 +++++++ .../screens/send/regular_send_strategy.dart | 164 +++++ .../v2/screens/send/review_send_screen.dart | 223 ++----- .../screens/send/select_recipient_screen.dart | 31 +- .../lib/v2/screens/send/send_strategy.dart | 188 ++++++ ..._screen.dart => send_terminal_screen.dart} | 102 +-- 24 files changed, 950 insertions(+), 1815 deletions(-) create mode 100644 mobile-app/lib/v2/components/explorer_link.dart delete mode 100644 mobile-app/lib/v2/screens/multisig/propose/propose_amount_screen.dart delete mode 100644 mobile-app/lib/v2/screens/multisig/propose/propose_done_screen.dart delete mode 100644 mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart delete mode 100644 mobile-app/lib/v2/screens/multisig/propose/propose_review_screen.dart create mode 100644 mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart create mode 100644 mobile-app/lib/v2/screens/send/regular_send_strategy.dart create mode 100644 mobile-app/lib/v2/screens/send/send_strategy.dart rename mobile-app/lib/v2/screens/send/{tx_submitted_screen.dart => send_terminal_screen.dart} (58%) diff --git a/mobile-app/lib/features/components/shared_address_action_sheet.dart b/mobile-app/lib/features/components/shared_address_action_sheet.dart index 796d59c5..f430135f 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -10,6 +10,7 @@ import 'package:resonance_network_wallet/shared/extensions/current_route_extensi import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; class SharedAddressActionSheet extends StatefulWidget { final String address; @@ -58,7 +59,12 @@ class _SharedAddressActionSheetState extends State { void _sendToAddress() { Navigator.of(context).pop(); - Navigator.push(context, MaterialPageRoute(builder: (_) => InputAmountScreen(recipientAddress: widget.address))); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InputAmountScreen(strategy: const RegularSendStrategy(), recipientAddress: widget.address), + ), + ); } void _closeSheet() { diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index af37d696..09207eec 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -2461,7 +2461,7 @@ "description": "Empty state on refund address picker" }, - "componentQrScannerTitle": "Scan QR Code", + "componentQrScannerTitle": "X QR Code", "@componentQrScannerTitle": { "description": "Text for app bar or button label on QR scanner component" }, diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 6561199c..3682f951 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -3179,7 +3179,7 @@ abstract class AppLocalizations { /// Text for app bar or button label on QR scanner component /// /// In en, this message translates to: - /// **'Scan QR Code'** + /// **'X QR Code'** String get componentQrScannerTitle; /// Snackbar when gallery image has no QR code diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index f4274950..89489452 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1689,7 +1689,7 @@ class AppLocalizationsEn extends AppLocalizations { String get swapRefundPickerEmpty => 'No recent refund addresses'; @override - String get componentQrScannerTitle => 'Scan QR Code'; + String get componentQrScannerTitle => 'X QR Code'; @override String get componentQrScannerNoCode => 'No QR code found in image'; diff --git a/mobile-app/lib/services/transaction_submission_service.dart b/mobile-app/lib/services/transaction_submission_service.dart index 0de9dade..4e755124 100644 --- a/mobile-app/lib/services/transaction_submission_service.dart +++ b/mobile-app/lib/services/transaction_submission_service.dart @@ -27,7 +27,7 @@ class TransactionSubmissionService { TransactionSubmissionService(this._ref) : _poller = PendingTransactionPollingService(_ref); - Future balanceTransfer( + Future balanceTransfer( Account account, String targetAddress, BigInt amount, @@ -52,13 +52,13 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_transfer'); // C. Submit and track the transaction - await submitAndTrackTransaction(() => BalancesService().balanceTransfer(account, targetAddress, amount), pendingTx); + return submitAndTrackTransaction(() => BalancesService().balanceTransfer(account, targetAddress, amount), pendingTx); } /// Broadcasts a transfer whose signature was produced off-device (e.g. by a /// Keystone hardware wallet). The [unsignedData] is rebuilt into an extrinsic /// using the externally provided [signature] and [publicKey]. - Future submitExternallySignedTransfer({ + Future submitExternallySignedTransfer({ required Account account, required String targetAddress, required BigInt amount, @@ -80,7 +80,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_transfer_hardware'); - await submitAndTrackTransaction( + return submitAndTrackTransaction( () => SubstrateService().submitExtrinsicWithExternalSignature(unsignedData, signature, publicKey), pendingTx, ); @@ -400,7 +400,10 @@ class TransactionSubmissionService { /// Retries live in SubstrateService.submitExtrinsic, which resubmits the /// same signed bytes. Outer retries would re-sign with a fresh nonce and can /// double spend if a prior submit already reached the network. - Future submitAndTrackTransaction(Future Function() submit, PendingTransactionEvent pendingTx) async { + Future submitAndTrackTransaction( + Future Function() submit, + PendingTransactionEvent pendingTx, + ) async { try { quantusDebugPrint('Submitting transaction: ${pendingTx.id}'); @@ -413,6 +416,7 @@ class TransactionSubmissionService { .updateState(pendingTx.id, TransactionState.pending, error: pendingTx.error, extrinsicHash: extrinsicHash); _startPollingForTransaction(pendingTx.copyWith(extrinsicHash: extrinsicHash)); + return extrinsicHash; } catch (e, stackTrace) { quantusDebugPrint('Failed to submit transaction ${pendingTx.id}: $e'); quantusDebugPrint('Stack trace: $stackTrace'); diff --git a/mobile-app/lib/shared/utils/url_utils.dart b/mobile-app/lib/shared/utils/url_utils.dart index 765411fa..d74d3c9d 100644 --- a/mobile-app/lib/shared/utils/url_utils.dart +++ b/mobile-app/lib/shared/utils/url_utils.dart @@ -1,5 +1,10 @@ +import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:url_launcher/url_launcher.dart'; +/// Block-explorer URL for an immediate (single-signer) transfer extrinsic. +String explorerImmediateTransactionUrl(String extrinsicHash) => + '${AppConstants.explorerEndpoint}/immediate-transactions/$extrinsicHash'; + Future launchXPost(String xUrl) async { final match = RegExp(r'/status/(\d+)').firstMatch(xUrl); if (match != null) { diff --git a/mobile-app/lib/v2/components/explorer_link.dart b/mobile-app/lib/v2/components/explorer_link.dart new file mode 100644 index 00000000..ed5634c7 --- /dev/null +++ b/mobile-app/lib/v2/components/explorer_link.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/shared/utils/open_external_url.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +/// Underlined "View in Explorer ↗" link that opens [url] in an external +/// browser. Shared across the send terminal, POS receipt and the +/// transaction/proposal detail sheets. Renders disabled (non-tappable) when +/// [url] is null or [enabled] is false; [color] defaults to the tertiary text. +class ExplorerLink extends ConsumerWidget { + final String? url; + final Color? color; + final bool enabled; + + const ExplorerLink({super.key, required this.url, this.color, this.enabled = true}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(l10nProvider); + final linkColor = color ?? context.colors.textTertiary; + final active = enabled && url != null; + + return GestureDetector( + onTap: active ? () => openUrl(url!) : null, + child: Container( + padding: const EdgeInsets.only(bottom: 3), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: linkColor, width: 1))), + child: Text( + l10n.activityDetailViewExplorer, + style: context.themeText.smallParagraph?.copyWith(color: linkColor, fontWeight: FontWeight.w400), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart index a47a5700..d6094774 100644 --- a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart +++ b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart @@ -9,9 +9,9 @@ import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/routes.dart'; import 'package:resonance_network_wallet/shared/extensions/current_route_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/transaction_event_extension.dart'; -import 'package:resonance_network_wallet/shared/utils/open_external_url.dart'; import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; import 'package:resonance_network_wallet/v2/components/bottom_sheet_container.dart'; +import 'package:resonance_network_wallet/v2/components/explorer_link.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -101,7 +101,6 @@ class _TransactionDetailSheet extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = ref.watch(l10nProvider); final colors = context.colors; - final text = context.themeText; return BottomSheetContainer( title: _title(l10n), @@ -129,7 +128,7 @@ class _TransactionDetailSheet extends ConsumerWidget { _DetailsSection(tx: tx, isSend: _isSend, activeAccountId: activeAccountId, colors: colors), const SizedBox(height: 24), Center( - child: _ExplorerLink(tx: tx, colors: colors, text: text), + child: _ExplorerLink(tx: tx, colors: colors), ), const SizedBox(height: 8), ], @@ -598,13 +597,11 @@ class _DetailRow extends StatelessWidget { class _ExplorerLink extends ConsumerWidget { final TransactionEvent tx; final AppColorsV2 colors; - final AppTextTheme text; - const _ExplorerLink({required this.tx, required this.colors, required this.text}); + const _ExplorerLink({required this.tx, required this.colors}); @override Widget build(BuildContext context, WidgetRef ref) { - final l10n = ref.watch(l10nProvider); final isPending = tx is PendingTransactionEvent || tx is PendingMultisigCreationEvent || @@ -613,22 +610,10 @@ class _ExplorerLink extends ConsumerWidget { tx is PendingMultisigCancellationEvent; final color = isPending ? colors.accentOrange.withValues(alpha: 0.3) : colors.accentOrange; - return GestureDetector( - onTap: isPending ? null : () => _openExplorer(), - child: Container( - padding: const EdgeInsets.only(bottom: 2), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: color, width: 1)), - ), - child: Text( - l10n.activityDetailViewExplorer, - style: text.smallParagraph?.copyWith(color: color, fontWeight: FontWeight.w400), - ), - ), - ); + return ExplorerLink(url: _explorerUrl(), color: color, enabled: !isPending); } - void _openExplorer() { + String? _explorerUrl() { final isMinerReward = tx.isMinerReward; final isMultisigCreated = tx.isMultisigCreated; final isProposalCreated = tx.isProposalCreation; @@ -666,6 +651,6 @@ class _ExplorerLink extends ConsumerWidget { path = '$transactionType/${tx.blockHash}'; } - if (path != null) openUrl('${AppConstants.explorerEndpoint}/$path'); + return path == null ? null : '${AppConstants.explorerEndpoint}/$path'; } } diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index c9db8167..b53ee82b 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -23,8 +23,9 @@ import 'package:resonance_network_wallet/v2/screens/activity/transaction_detail_ import 'package:resonance_network_wallet/v2/screens/receive/receive_screen.dart'; import 'package:resonance_network_wallet/v2/screens/multisig/multisig_activity_section.dart'; import 'package:resonance_network_wallet/v2/screens/multisig/multisig_proposal_detail_sheet.dart'; -import 'package:resonance_network_wallet/v2/screens/multisig/propose/propose_recipient_screen.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/multisig_propose_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/send/select_recipient_screen.dart'; import 'package:resonance_network_wallet/v2/screens/settings/settings_screen.dart'; import 'package:resonance_network_wallet/v2/screens/pos/pos_amount_screen.dart'; @@ -96,7 +97,12 @@ class _HomeScreenState extends ConsumerState { ref.read(paymentIntentProvider.notifier).state = null; final pageRoute = MaterialPageRoute( - builder: (_) => InputAmountScreen(recipientAddress: payment.to, initialAmount: payment.amount, isPayMode: true), + builder: (_) => InputAmountScreen( + strategy: const RegularSendStrategy(), + recipientAddress: payment.to, + initialAmount: payment.amount, + isPayMode: true, + ), settings: inputAmountScreenRouteSettings, ); @@ -322,7 +328,10 @@ class _HomeScreenState extends ConsumerState { final sendCard = _actionCard( iconAsset: 'assets/v2/action_send.svg', label: l10n.homeSend, - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SelectRecipientScreen())), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SelectRecipientScreen(strategy: RegularSendStrategy())), + ), ); final swapCard = _actionCard( @@ -357,7 +366,10 @@ class _HomeScreenState extends ConsumerState { _actionCard( iconAsset: 'assets/v2/action_send.svg', label: l10n.multisigProposeTitle, - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => ProposeRecipientScreen(msig: msig))), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig))), + ), ), ], ); diff --git a/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart index fc1605aa..d079727b 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart @@ -14,10 +14,10 @@ import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/v2/components/multisig_expiry_value.dart'; import 'package:resonance_network_wallet/routes.dart'; import 'package:resonance_network_wallet/shared/extensions/current_route_extensions.dart'; -import 'package:resonance_network_wallet/shared/utils/open_external_url.dart'; import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; import 'package:resonance_network_wallet/v2/components/bottom_sheet_container.dart'; import 'package:resonance_network_wallet/v2/components/detail_summary_row.dart'; +import 'package:resonance_network_wallet/v2/components/explorer_link.dart'; import 'package:resonance_network_wallet/v2/components/loader.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/screens/multisig/multisig_approve_confirm_sheet.dart'; @@ -232,7 +232,10 @@ class _MultisigProposalDetailSheet extends ConsumerWidget { isActionable: isActionable, ), Center( - child: _ExplorerLink(proposal: liveProposal, colors: colors, text: text), + child: ExplorerLink( + url: '${AppConstants.explorerEndpoint}/multisig-proposals/${liveProposal.explorerProposalId}', + color: colors.accentOrange, + ), ), const SizedBox(height: 8), ], @@ -627,30 +630,3 @@ class _AmountSection extends ConsumerWidget { return AmountDisplayWithConversion(amountDisplay: amount); } } - -class _ExplorerLink extends ConsumerWidget { - final MultisigProposal proposal; - final AppColorsV2 colors; - final AppTextTheme text; - - const _ExplorerLink({required this.proposal, required this.colors, required this.text}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = ref.watch(l10nProvider); - - return GestureDetector( - onTap: () => openUrl('${AppConstants.explorerEndpoint}/multisig-proposals/${proposal.explorerProposalId}'), - child: Container( - padding: const EdgeInsets.only(bottom: 2), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colors.accentOrange, width: 1)), - ), - child: Text( - l10n.activityDetailViewExplorer, - style: text.smallParagraph?.copyWith(color: colors.accentOrange, fontWeight: FontWeight.w400), - ), - ), - ); - } -} diff --git a/mobile-app/lib/v2/screens/multisig/propose/propose_amount_screen.dart b/mobile-app/lib/v2/screens/multisig/propose/propose_amount_screen.dart deleted file mode 100644 index 60d092ea..00000000 --- a/mobile-app/lib/v2/screens/multisig/propose/propose_amount_screen.dart +++ /dev/null @@ -1,592 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/l10n/app_localizations.dart'; -import 'package:resonance_network_wallet/models/fiat_currency.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; -import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; -import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/multisig_providers.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; -import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.dart'; -import 'package:resonance_network_wallet/shared/utils/amount_input_logic.dart'; -import 'package:resonance_network_wallet/shared/utils/debouncer.dart'; -import 'package:resonance_network_wallet/v2/components/loader.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_icon_button.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; -import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; -import 'package:resonance_network_wallet/v2/screens/multisig/propose/propose_review_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/send/send_screen_logic.dart'; -import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; -import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; - -class ProposeAmountScreen extends ConsumerStatefulWidget { - final MultisigAccount msig; - final String recipientAddress; - final String? recipientChecksum; - final String? initialAmount; - final bool isPayMode; - - const ProposeAmountScreen({ - super.key, - required this.msig, - required this.recipientAddress, - this.recipientChecksum, - this.initialAmount, - this.isPayMode = false, - }); - - @override - ConsumerState createState() => _ProposeAmountScreenState(); -} - -class _ProposeAmountScreenState extends ConsumerState { - final _amountController = TextEditingController(); - final _amountFocus = FocusNode(); - final _scrollController = ScrollController(); - final _amountCenterKey = GlobalKey(); - final _feeDebouncer = Debouncer(delay: const Duration(milliseconds: 500)); - - static final BigInt _estimateFeeAmount = BigInt.from(1000) * NumberFormattingService.scaleFactorBigInt; - - String? _recipientChecksum; - BigInt _amount = BigInt.zero; - ProposeFeeBreakdown? _feeBreakdown; - bool _isFetchingFee = false; - bool _hasFee = false; - bool _feeFetchFailed = false; - int _feeFetchGeneration = 0; - - AmountInputLogic get _amountInputLogic => AmountInputLogic( - exchangeRateService: ref.read(exchangeRateServiceProvider), - selectedFiat: ref.read(selectedFiatCurrencyProvider), - localeConfig: ref.read(localeNumberConfigProvider), - formattingService: ref.read(numberFormattingServiceProvider), - ); - - @override - void initState() { - super.initState(); - assert(widget.recipientAddress.trim().isNotEmpty, 'ProposeAmountScreen requires a recipient'); - _amountFocus.addListener(_onAmountFocusChanged); - if (widget.initialAmount != null && widget.initialAmount!.isNotEmpty) { - final formattingService = ref.read(numberFormattingServiceProvider); - final planck = widget.isPayMode - ? formattingService.parseWireAmount(widget.initialAmount!) ?? BigInt.zero - : _amountInputLogic.parseQuanAmount(widget.initialAmount!); - if (planck > BigInt.zero) { - _amount = planck; - _amountController.text = _amountInputLogic.formatQuanAmount(planck); - } - } - if (widget.recipientChecksum != null) { - _recipientChecksum = widget.recipientChecksum; - } else { - ref.read(humanReadableChecksumServiceProvider).getHumanReadableName(widget.recipientAddress.trim()).then((name) { - if (mounted) setState(() => _recipientChecksum = name); - }); - } - _refreshFee(); - } - - @override - void dispose() { - _feeDebouncer.cancel(); - _amountController.dispose(); - _amountFocus.removeListener(_onAmountFocusChanged); - _amountFocus.dispose(); - _scrollController.dispose(); - super.dispose(); - } - - void _onAmountFocusChanged() { - if (!_amountFocus.hasFocus) return; - Future.delayed(const Duration(milliseconds: 300), () { - if (!mounted) return; - final ctx = _amountCenterKey.currentContext; - if (ctx != null) { - Scrollable.ensureVisible( - // ignore: use_build_context_synchronously - ctx, - alignment: 0.5, - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - ); - } - }); - } - - void _onAmountChanged(String _) { - HapticFeedback.mediumImpact(); - - final isFlipped = widget.isPayMode ? false : ref.read(isCurrencyFlippedProvider); - try { - setState(() => _amount = _amountInputLogic.onAmountChanged(value: _amountController.text, isFlipped: isFlipped)); - } on InvalidNumberInputException catch (e, stack) { - debugPrint('Amount parse failed: $e\n$stack'); - context.showErrorToaster(message: ref.read(l10nProvider).sendInputAmountInvalidAmount); - return; - } - _feeDebouncer.run(_refreshFee); - } - - void _refreshFee() { - final recipient = widget.recipientAddress.trim(); - if (_amount > BigInt.zero && ref.read(substrateServiceProvider).isValidSS58Address(recipient)) { - _fetchFee(_amount, recipient); - } else { - _fetchEstimatedFee(); - } - } - - void _retryFeeFetch() { - _feeDebouncer.cancel(); - _refreshFee(); - } - - Future _fetchEstimatedFee() async { - _fetchFee(_estimateFeeAmount, widget.recipientAddress.trim()); - } - - ProposeFeeBreakdown _staticFeeBreakdown(MultisigService service, int expiryBlock) { - return ProposeFeeBreakdown( - networkFee: BigInt.zero, - deposit: service.proposalDeposit, - creationFee: service.proposalCreationFee(widget.msig.signers.length), - expiryBlock: expiryBlock, - ); - } - - Future _fetchFee(BigInt amount, String recipient) async { - final generation = ++_feeFetchGeneration; - final showLoader = !_hasFee || _feeFetchFailed; - final service = ref.read(multisigServiceProvider); - Account? signer; - final accounts = ref.read(accountsProvider).value; - if (accounts != null) { - for (final account in accounts) { - if (account.accountId == widget.msig.myMemberAccountId) { - signer = account; - break; - } - } - } - setState(() { - _isFetchingFee = showLoader; - if (showLoader) _feeFetchFailed = false; - }); - try { - final currentBlock = await service.currentBlockNumber(); - final expiryBlock = currentBlock + service.blocksForDuration(MultisigService.defaultProposalExpiry); - final breakdown = signer != null - ? await service.estimateProposeFeeBreakdown( - msig: widget.msig, - signer: signer, - recipient: recipient, - amount: amount, - ) - : _staticFeeBreakdown(service, expiryBlock); - if (!mounted || generation != _feeFetchGeneration) return; - setState(() { - _feeBreakdown = breakdown; - _hasFee = true; - _feeFetchFailed = false; - _isFetchingFee = false; - }); - } catch (e, stack) { - debugPrint('Propose fee fetch error: $e\n$stack'); - if (!mounted || generation != _feeFetchGeneration) return; - setState(() { - _feeBreakdown = null; - _hasFee = false; - _feeFetchFailed = true; - _isFetchingFee = false; - }); - } - } - - void _setMax() { - final balance = ref.read(balanceProviderFamily(widget.msig.accountId)).value ?? BigInt.zero; - final isFlipped = ref.read(isCurrencyFlippedProvider); - _amountController.text = isFlipped - ? _amountInputLogic.quanToFiatString(balance) - : _amountInputLogic.formatQuanAmount(balance); - setState(() => _amount = balance); - _refreshFee(); - } - - Future _toggleFlip() async { - final wasFlipped = ref.read(isCurrencyFlippedProvider); - await ref.read(isCurrencyFlippedProvider.notifier).toggle(); - - final result = _amountInputLogic.getToggledInput(wasFlipped: wasFlipped, currentAmount: _amount); - - setState(() { - _amountController.text = result.text; - _amount = result.amount; - }); - } - - void _openReview() { - if (_recipientChecksum == null || _feeBreakdown == null) { - context.showErrorToaster(message: ref.read(l10nProvider).sendInputAmountChecksumRequired); - return; - } - - FocusScope.of(context).unfocus(); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProposeReviewScreen( - msig: widget.msig, - recipientAddress: widget.recipientAddress, - recipientChecksum: _recipientChecksum!, - amount: _amount, - feeBreakdown: _feeBreakdown!, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final l10n = ref.watch(l10nProvider); - final colors = context.colors; - final text = context.themeText; - final balance = ref.watch(balanceProviderFamily(widget.msig.accountId)); - final memberBalance = ref.watch(effectiveBalanceProviderFamily(widget.msig.myMemberAccountId)); - final formattingService = ref.read(numberFormattingServiceProvider); - final recipient = widget.recipientAddress.trim(); - - final multisigBalance = balance.value; - final memberBal = memberBalance.value; - final proposalFee = _feeBreakdown?.memberCost; - - final amountStatus = SendScreenLogic.getAmountStatus(_amount, multisigBalance ?? BigInt.zero, BigInt.zero); - final multisigInsufficient = amountStatus == AmountStatus.insufficientBalance; - final memberInsufficient = proposalFee != null && memberBal != null && memberBal < proposalFee; - final balancesLoading = balance.isLoading || memberBalance.isLoading; - - final btnDisabled = - !_hasFee || - _feeFetchFailed || - _recipientChecksum == null || - balancesLoading || - memberInsufficient || - SendScreenLogic.isButtonDisabled( - hasAddressError: false, - amountStatus: amountStatus, - recipientText: recipient, - activeAccountId: widget.msig.accountId, - ); - final btnText = memberInsufficient - ? l10n.sendLogicInsufficientBalance - : multisigInsufficient - ? l10n.sendLogicInsufficientBalance - : amountStatus == AmountStatus.valid - ? l10n.multisigProposeReviewButton - : SendScreenLogic.getButtonText( - l10n: l10n, - hasAddressError: false, - amountStatus: amountStatus, - recipientText: recipient, - amount: _amount, - activeAccountId: widget.msig.accountId, - formattingService: formattingService, - ); - - return ScaffoldBase( - appBar: V2AppBar(title: widget.isPayMode ? l10n.sendPayTitle : l10n.multisigProposeTitle), - mainContent: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - controller: _scrollController, - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _recipientCard(colors, text, l10n), - const SizedBox(height: 32), - _amountCenter(colors, text), - const SizedBox(height: 32), - const SizedBox.shrink(), - ], - ), - ), - ), - ), - bottomContent: _bottomSection(colors, text, l10n, btnText, balance, btnDisabled), - ); - } - - Widget _recipientCard(AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n) { - final addr = widget.recipientAddress.trim(); - final shortAddr = AddressFormattingService.formatAddress(addr); - - return Container( - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), - decoration: BoxDecoration(color: colors.surfaceDeep, borderRadius: BorderRadius.circular(14)), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.multisigProposeAmountToLabel, - style: context.themeText.receiveLabel?.copyWith(color: colors.textLabel), - ), - const SizedBox(height: 16), - if (_recipientChecksum != null) ...[ - Text( - _recipientChecksum!, - style: text.smallParagraph?.copyWith(color: colors.checksum, height: 1.2), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - ], - Text( - shortAddr, - style: text.detail?.copyWith( - color: colors.textMuted, - fontFamily: AppTextTheme.fontFamilySecondary, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Material( - color: colors.background, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: () => Navigator.of(context).pop(true), - child: Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: colors.borderButton), - ), - child: Icon(Icons.edit_outlined, size: 18, color: colors.textPrimary), - ), - ), - ), - ], - ), - ); - } - - Widget _amountCenter(AppColorsV2 colors, AppTextTheme text) { - final isPayMode = widget.isPayMode; - final isFlipped = isPayMode ? false : ref.watch(isCurrencyFlippedProvider); - final selectedFiat = ref.watch(selectedFiatCurrencyProvider); - final localeConfig = ref.watch(localeNumberConfigProvider); - final display = ref.watch(txAmountDisplayProvider)( - _amount, - withSignPrefix: false, - quanDecimals: 4, - isSend: true, - withQuanSymbol: false, - ); - - final symbolStyle = text.transactionDetailAmountSymbol?.copyWith(color: colors.textPrimary); - final isPrefixFiat = isFlipped && selectedFiat.symbolPosition == SymbolPosition.prefix; - - final maxDecimals = isFlipped ? selectedFiat.decimals : null; - final inputField = IntrinsicWidth( - child: TextField( - controller: _amountController, - focusNode: _amountFocus, - onChanged: _onAmountChanged, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - textAlign: isPrefixFiat ? TextAlign.left : TextAlign.right, - inputFormatters: [DecimalInputFilter(localeConfig: localeConfig, maxDecimalPlaces: maxDecimals)], - style: text.transactionDetailAmountPrimary?.copyWith( - color: _amount == BigInt.zero ? colors.textTertiary : colors.textPrimary, - ), - decoration: InputDecoration( - isDense: true, - hintText: '0', - hintStyle: text.transactionDetailAmountPrimary?.copyWith(color: colors.textTertiary), - ), - ), - ); - - final symbolWidget = Text(isFlipped ? selectedFiat.symbol : AppConstants.tokenSymbol, style: symbolStyle); - - final List primaryRowChildren = isPrefixFiat - ? [symbolWidget, const SizedBox(width: 8), inputField] - : [inputField, const SizedBox(width: 8), symbolWidget]; - - return Center( - key: _amountCenterKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: primaryRowChildren, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '≈ ${display.secondaryAmount}', - style: text.paragraph?.copyWith( - color: colors.textTertiary, - fontFamily: AppTextTheme.fontFamilySecondary, - ), - ), - if (!isPayMode) ...[ - const SizedBox(width: 8), - QuantusIconButton.circular( - icon: Icons.swap_vert, - onTap: _toggleFlip, - isActive: display.isFlipped, - size: IconButtonSize.small, - ), - ], - ], - ), - ], - ), - ); - } - - Widget _bottomSection( - AppColorsV2 colors, - AppTextTheme text, - AppLocalizations l10n, - String btnText, - AsyncValue balance, - bool btnDisabled, - ) { - final formattingService = ref.read(numberFormattingServiceProvider); - - return ScaffoldBaseBottomContent( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.sendInputAmountAvailableBalance, - style: text.smallParagraph?.copyWith(color: colors.textTertiary), - ), - const SizedBox(height: 4), - balance.when( - data: (b) => Text( - l10n.commonAmountBalance(formattingService.formatBalance(b), AppConstants.tokenSymbol), - style: text.smallParagraph?.copyWith(color: colors.textTertiary), - ), - loading: () => Text('...', style: text.smallParagraph?.copyWith(color: colors.textTertiary)), - error: (_, _) => Text('—', style: text.smallParagraph?.copyWith(color: colors.textTertiary)), - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - l10n.multisigProposeFeeLabel, - style: text.smallParagraph?.copyWith(color: colors.textTertiary), - ), - const SizedBox(height: 4), - if (_isFetchingFee) - const Align(alignment: Alignment.centerRight, child: Loader()) - else if (_feeFetchFailed) - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - l10n.multisigProposeFeeFetchFailed, - style: text.smallParagraph?.copyWith(color: colors.error), - textAlign: TextAlign.right, - ), - const SizedBox(height: 4), - IntrinsicWidth( - child: QuantusButton.simple( - label: l10n.homeActivityRetry, - onTap: _retryFeeFetch, - padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), - variant: ButtonVariant.transparent, - textStyle: text.smallParagraph?.copyWith( - color: colors.accentOrange, - decoration: TextDecoration.underline, - decorationColor: colors.accentOrange, - ), - ), - ), - ], - ) - else if (_hasFee && _feeBreakdown != null) - Text( - l10n.commonAmountBalance( - formattingService.formatBalance(_feeBreakdown!.memberCost, smartDecimals: 5), - AppConstants.tokenSymbol, - ), - style: text.smallParagraph?.copyWith(color: colors.textTertiary), - ) - else - const Align(alignment: Alignment.centerRight, child: Loader()), - ], - ), - ), - ], - ), - const SizedBox(height: 4), - IntrinsicWidth( - child: QuantusButton.simple( - label: l10n.sendInputAmountMax, - onTap: _setMax, - padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), - variant: ButtonVariant.transparent, - textStyle: text.smallParagraph?.copyWith( - color: colors.accentOrange, - decoration: TextDecoration.underline, - decorationColor: colors.accentOrange, - ), - ), - ), - ], - ), - const SizedBox(height: 32), - QuantusButton.simple( - label: btnText, - variant: ButtonVariant.primary, - isDisabled: btnDisabled, - onTap: _openReview, - ), - ], - ), - ); - } -} diff --git a/mobile-app/lib/v2/screens/multisig/propose/propose_done_screen.dart b/mobile-app/lib/v2/screens/multisig/propose/propose_done_screen.dart deleted file mode 100644 index 1ee32b8b..00000000 --- a/mobile-app/lib/v2/screens/multisig/propose/propose_done_screen.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; -import 'package:resonance_network_wallet/v2/components/back_button.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; -import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; -import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; -import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; -import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; - -class ProposeDoneScreen extends ConsumerWidget { - final MultisigAccount msig; - final String recipientAddress; - final String recipientChecksum; - final BigInt amount; - - const ProposeDoneScreen({ - super.key, - required this.msig, - required this.recipientAddress, - required this.recipientChecksum, - required this.amount, - }); - - void _popToHome(BuildContext context) { - Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (_) => const HomeScreen()), (route) => false); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = ref.watch(l10nProvider); - final colors = context.colors; - final text = context.themeText; - final fmt = ref.watch(numberFormattingServiceProvider); - final amountText = l10n.commonAmountBalance(fmt.formatBalance(amount, smartDecimals: 4), AppConstants.tokenSymbol); - final shortAddr = AddressFormattingService.formatAddress(recipientAddress.trim()); - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - _popToHome(context); - }, - child: ScaffoldBase( - appBar: V2AppBar( - title: l10n.multisigProposeTitle, - leading: AppBackButton(onTap: () => _popToHome(context)), - ), - mainContent: Column( - children: [ - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _successMark(colors), - const SizedBox(height: 32), - Text( - l10n.multisigProposeDoneHeadline, - textAlign: TextAlign.center, - style: text.largeTitle?.copyWith(fontWeight: FontWeight.w400), - ), - const SizedBox(height: 4), - Text( - l10n.multisigProposeDoneSubline, - textAlign: TextAlign.center, - style: text.smallParagraph?.copyWith(color: colors.textTertiary, letterSpacing: 0.74), - ), - const SizedBox(height: 32), - Text(amountText, style: text.smallTitle?.copyWith(color: colors.textPrimary)), - const SizedBox(height: 16), - Text.rich( - textAlign: TextAlign.center, - TextSpan( - style: text.paragraph?.copyWith(color: colors.textPrimary), - children: [ - TextSpan( - text: l10n.sendTxSubmittedToLabel, - style: text.paragraph?.copyWith(fontWeight: FontWeight.w500), - ), - TextSpan( - text: ':', - style: text.paragraph?.copyWith(fontWeight: FontWeight.w600), - ), - ], - ), - ), - const SizedBox(height: 16), - Text( - recipientChecksum, - textAlign: TextAlign.center, - style: text.smallParagraph?.copyWith(color: colors.checksum, height: 1.0), - ), - const SizedBox(height: 4), - Text( - shortAddr, - textAlign: TextAlign.center, - style: text.smallParagraph?.copyWith( - color: colors.textPrimary, - fontWeight: FontWeight.w500, - fontFamily: AppTextTheme.fontFamilySecondary, - height: 1.35, - ), - ), - const SizedBox(height: 32), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: colors.surfaceDeep, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colors.borderButton.useOpacity(0.4)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.fingerprint, size: 18, color: colors.checksum), - const SizedBox(width: 8), - Text( - l10n.multisigSignaturesCount(1, msig.threshold), - style: text.smallParagraph?.copyWith(color: colors.textPrimary), - ), - ], - ), - ), - ], - ), - ), - ], - ), - bottomContent: ScaffoldBaseBottomContent( - child: QuantusButton.simple( - label: l10n.multisigDone, - variant: ButtonVariant.primary, - onTap: () => _popToHome(context), - ), - ), - ), - ); - } - - Widget _successMark(AppColorsV2 colors) { - return Container( - width: 78, - height: 78, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: colors.success, width: 2), - ), - alignment: Alignment.center, - child: Icon(Icons.check, size: 32, color: colors.success), - ); - } -} diff --git a/mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart b/mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart deleted file mode 100644 index 19d33f79..00000000 --- a/mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart +++ /dev/null @@ -1,414 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/features/components/dotted_border.dart'; -import 'package:resonance_network_wallet/features/components/skeleton.dart'; -import 'package:resonance_network_wallet/l10n/app_localizations.dart'; -import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; -import 'package:resonance_network_wallet/v2/components/address_checkphrase_with_initial.dart'; -import 'package:resonance_network_wallet/v2/components/loader.dart'; -import 'package:resonance_network_wallet/v2/components/qr_scanner_page.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; -import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; -import 'package:resonance_network_wallet/v2/screens/multisig/propose/propose_amount_screen.dart'; -import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; -import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; - -class ProposeRecipientScreen extends ConsumerStatefulWidget { - final MultisigAccount msig; - - const ProposeRecipientScreen({super.key, required this.msig}); - - @override - ConsumerState createState() => _ProposeRecipientScreenState(); -} - -class _ProposeRecipientScreenState extends ConsumerState { - final _amountController = TextEditingController(); - final _recipientController = TextEditingController(); - final _recipientFocus = FocusNode(); - - final Map _checksums = {}; - List _recents = []; - bool _loadingRecents = true; - bool _hasAddressError = true; - bool _isPayMode = false; - String? _recipientChecksum; - - @override - void initState() { - super.initState(); - _recipientController.addListener(_onRecipientChanged); - _loadRecents(); - } - - @override - void dispose() { - _recipientController.removeListener(_onRecipientChanged); - _recipientController.dispose(); - _amountController.dispose(); - _recipientFocus.dispose(); - super.dispose(); - } - - Future _loadRecents() async { - final checksumService = ref.read(humanReadableChecksumServiceProvider); - final recentService = ref.read(recentAddressesServiceProvider); - - try { - final all = await recentService.getAddresses(); - final addresses = all.where((a) => a != widget.msig.accountId).toList(); - if (!mounted) return; - setState(() { - _recents = addresses; - _loadingRecents = false; - }); - for (final addr in addresses) { - checksumService.getHumanReadableName(addr).then((name) { - if (mounted) setState(() => _checksums[addr] = name); - }); - } - } catch (e) { - debugPrint('ProposeRecipientScreen recents: $e'); - if (mounted) setState(() => _loadingRecents = false); - } - } - - void _onRecipientChanged() { - final text = _recipientController.text.trim(); - if (text.isEmpty) { - _amountController.clear(); - setState(() { - _hasAddressError = true; - _recipientChecksum = null; - _isPayMode = false; - }); - return; - } - _lookupAddress(text); - } - - void _lookupAddress(String address) { - final checksumService = ref.read(humanReadableChecksumServiceProvider); - final substrate = ref.read(substrateServiceProvider); - final isValid = substrate.isValidSS58Address(address); - setState(() { - _hasAddressError = !isValid; - _recipientChecksum = null; - }); - if (isValid) { - checksumService.getHumanReadableName(address).then((checksum) { - if (mounted) setState(() => _recipientChecksum = checksum); - }); - } - } - - bool get _canContinue { - final text = _recipientController.text.trim(); - if (text.isEmpty) return false; - if (_hasAddressError) return false; - if (text == widget.msig.accountId) return false; - return true; - } - - /// Single entry point for every way a recipient is supplied (scan, paste, - /// recent). The controller text is assigned last and outside [setState] so the - /// [_onRecipientChanged] listener drives validation and the continue button. - void _setRecipient(String address, {String amount = '', bool isPayMode = false}) { - _amountController.text = amount; - setState(() => _isPayMode = isPayMode); - _recipientController.text = address; - } - - Future _scanQr() async { - final substrate = ref.read(substrateServiceProvider); - final scanResult = await Navigator.push( - context, - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => QrScannerPage( - validator: (code) => substrate.isValidSS58Address(code) || PaymentIntent.tryParseUrl(code) != null, - ), - ), - ); - if (scanResult == null || !mounted) return; - final payment = PaymentIntent.tryParseUrl(scanResult); - if (payment != null) { - _setRecipient(payment.to, amount: payment.amount, isPayMode: true); - } else { - _setRecipient(scanResult); - } - } - - void _continue() { - if (!_canContinue) return; - - final address = _recipientController.text.trim(); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProposeAmountScreen( - msig: widget.msig, - recipientAddress: address, - recipientChecksum: _recipientChecksum, - initialAmount: _amountController.text, - isPayMode: _isPayMode, - ), - ), - ).then((popped) { - if (!mounted || popped != true) return; - _recipientController.clear(); - _amountController.clear(); - _isPayMode = false; - setState(() { - _recipientChecksum = null; - _hasAddressError = true; - }); - _loadRecents(); - }); - } - - void _onRecentTap(String address) => _setRecipient(address); - - Future _pasteRecipient() async { - final data = await Clipboard.getData(Clipboard.kTextPlain); - final text = data?.text?.trim() ?? ''; - if (text.isEmpty) return; - _setRecipient(text); - } - - @override - Widget build(BuildContext context) { - final l10n = ref.watch(l10nProvider); - final colors = context.colors; - final text = context.themeText; - - return ScaffoldBase( - appBar: V2AppBar(title: l10n.multisigProposeTitle), - mainContent: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - l10n.multisigProposeSelectRecipientTo, - style: text.sendSectionLabel?.copyWith(color: colors.textPrimary), - ), - const SizedBox(height: 12), - _buildRecipientField(colors, text, l10n), - const SizedBox(height: 28), - _buildScanRow(colors, text, l10n), - const SizedBox(height: 28), - DottedBorder( - dashLength: 3, - gapLength: 5, - color: colors.borderButton.useOpacity(0.5), - child: const SizedBox(width: double.infinity, height: 1), - ), - const SizedBox(height: 28), - ], - ), - Expanded( - child: CustomScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - if (_loadingRecents) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: Loader())) - else if (_recents.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Text( - l10n.sendSelectRecipientRecents, - style: text.smallTitle?.copyWith(color: colors.textPrimary), - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 32)), - SliverList( - delegate: SliverChildBuilderDelegate((context, i) { - final isFirst = i == 0; - final isLast = i == _recents.length - 1; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - if (!isFirst) ...[const SizedBox(height: 14)], - _recentRow(_recents[i], colors, text), - if (!isLast) ...[ - const SizedBox(height: 14), - Divider(height: 1, color: colors.txItemSeparator), - ], - ], - ); - }, childCount: _recents.length), - ), - ] else - const SliverFillRemaining(hasScrollBody: false, child: SizedBox.shrink()), - ], - ), - ), - ], - ), - bottomContent: _buildBottomButton(l10n), - ); - } - - Widget _buildRecipientField(AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n) { - final hasValid = _recipientController.text.trim().isNotEmpty && !_hasAddressError; - - return SizedBox( - height: 48, - child: Stack( - children: [ - Positioned.fill( - child: IgnorePointer( - ignoring: hasValid, - child: Opacity( - opacity: hasValid ? 0 : 1, - child: Container( - padding: const EdgeInsets.only(left: 12, right: 8), - decoration: BoxDecoration(color: colors.sheetBackground, borderRadius: BorderRadius.circular(8)), - child: Row( - children: [ - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _recipientController, - focusNode: _recipientFocus, - keyboardType: TextInputType.text, - textInputAction: TextInputAction.done, - autocorrect: false, - enableSuggestions: false, - textCapitalization: TextCapitalization.none, - scrollPadding: const EdgeInsets.only(bottom: 120), - style: text.smallParagraph?.copyWith(color: colors.textPrimary), - decoration: InputDecoration( - hintText: l10n.sendSelectRecipientSearchHint(AppConstants.tokenSymbol), - ), - ), - ), - IconButton( - onPressed: _pasteRecipient, - icon: const Icon(Icons.paste), - iconSize: 20, - color: colors.textPrimary, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), - ), - ], - ), - ), - ), - ), - ), - if (hasValid) - Positioned.fill( - child: GestureDetector( - onTap: () { - _recipientController.clear(); - _recipientFocus.requestFocus(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration(color: colors.toasterBackground, borderRadius: BorderRadius.circular(8)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - AddressFormattingService.formatAddress( - prefix: 16, - postFix: 16, - _recipientController.text.trim(), - ), - style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (_recipientChecksum != null) - Text(_recipientChecksum!, style: text.detail?.copyWith(color: colors.checksum)), - ], - ), - ), - ), - ), - ], - ), - ); - } - - Widget _buildScanRow(AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n) { - const iconContainerSize = 44.0; - const iconSize = 24.0; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: _scanQr, - borderRadius: BorderRadius.circular(12), - child: Row( - children: [ - Container( - width: iconContainerSize, - height: iconContainerSize, - decoration: BoxDecoration( - color: colors.background, - borderRadius: BorderRadius.circular(36), - border: Border.all(color: colors.borderButton), - ), - child: Icon(Icons.qr_code_scanner, size: iconSize, color: colors.textPrimary), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.sendSelectRecipientScanTitle, style: text.paragraph?.copyWith(color: colors.textPrimary)), - const SizedBox(height: 4), - Text( - l10n.sendSelectRecipientScanSubtitle(AppConstants.tokenSymbol), - style: text.detail?.copyWith(color: colors.textTertiary), - ), - ], - ), - ), - Icon(Icons.chevron_right, size: 20, color: colors.textPrimary), - ], - ), - ), - ); - } - - Widget _recentRow(String address, AppColorsV2 colors, AppTextTheme text) { - final checksum = _checksums[address]; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _onRecentTap(address), - borderRadius: BorderRadius.circular(8), - child: checksum != null - ? AddressCheckphraseWithInitial(recipientChecksum: checksum, recipientAddress: address) - : const Skeleton(height: 36), - ), - ); - } - - Widget _buildBottomButton(AppLocalizations l10n) { - final btnText = _canContinue ? l10n.sendSelectRecipientContinue : l10n.sendEnterAddress; - - return ScaffoldBaseBottomContent( - child: QuantusButton.simple( - label: btnText, - variant: ButtonVariant.primary, - isDisabled: !_canContinue, - onTap: _continue, - ), - ); - } -} diff --git a/mobile-app/lib/v2/screens/multisig/propose/propose_review_screen.dart b/mobile-app/lib/v2/screens/multisig/propose/propose_review_screen.dart deleted file mode 100644 index 7126d335..00000000 --- a/mobile-app/lib/v2/screens/multisig/propose/propose_review_screen.dart +++ /dev/null @@ -1,269 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/l10n/app_localizations.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; -import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; -import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/multisig_providers.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; -import 'package:resonance_network_wallet/v2/components/detail_summary_row.dart'; -import 'package:resonance_network_wallet/v2/components/multisig_expiry_value.dart'; -import 'package:resonance_network_wallet/services/local_auth_service.dart'; -import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; -import 'package:resonance_network_wallet/v2/components/address_checkphrase_with_initial.dart'; -import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; -import 'package:resonance_network_wallet/v2/components/split_card.dart'; -import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; -import 'package:resonance_network_wallet/v2/screens/multisig/propose/propose_done_screen.dart'; -import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; -import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; - -class ProposeReviewScreen extends ConsumerStatefulWidget { - final MultisigAccount msig; - final String recipientAddress; - final String recipientChecksum; - final BigInt amount; - final ProposeFeeBreakdown feeBreakdown; - - const ProposeReviewScreen({ - super.key, - required this.msig, - required this.recipientAddress, - required this.recipientChecksum, - required this.amount, - required this.feeBreakdown, - }); - - @override - ConsumerState createState() => _ProposeReviewScreenState(); -} - -class _ProposeReviewScreenState extends ConsumerState { - bool _submitting = false; - String? _errorMessage; - - Future _toggleFlip() async { - await ref.read(isCurrencyFlippedProvider.notifier).toggle(); - } - - Future _submit() async { - setState(() { - _submitting = true; - _errorMessage = null; - }); - final l10n = ref.read(l10nProvider); - final authed = await LocalAuthService().authenticate(localizedReason: l10n.multisigProposeAuthReason); - if (!authed || !mounted) { - setState(() { - _submitting = false; - _errorMessage = l10n.multisigProposeAuthRequired; - }); - return; - } - try { - final signer = ref - .read(accountsProvider) - .value - ?.firstWhere( - (a) => a.accountId == widget.msig.myMemberAccountId, - orElse: () => throw Exception('Member account not found in local wallet'), - ); - if (signer == null) throw Exception('No signer account available'); - - await ref - .read(transactionSubmissionServiceProvider) - .proposeTransfer( - msig: widget.msig, - signer: signer, - recipient: widget.recipientAddress, - amount: widget.amount, - expiryBlock: widget.feeBreakdown.expiryBlock, - feeBreakdown: widget.feeBreakdown, - ); - - unawaited( - RecentAddressesService() - .addAddress(widget.recipientAddress.trim()) - .catchError((Object e) => debugPrint('Failed to save recent address: $e')), - ); - - if (!mounted) return; - ref.invalidate(multisigOpenProposalsProvider(widget.msig)); - ref.invalidate(multisigPastProposalsProvider(widget.msig)); - ref.invalidate(multisigCurrentBlockProvider); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProposeDoneScreen( - msig: widget.msig, - recipientAddress: widget.recipientAddress, - recipientChecksum: widget.recipientChecksum, - amount: widget.amount, - ), - ), - ); - } catch (e, st) { - debugPrint('Propose submit error: $e $st'); - if (!mounted) return; - setState(() { - _submitting = false; - _errorMessage = ref.read(l10nProvider).multisigProposeSubmitFailed; - }); - } - } - - @override - Widget build(BuildContext context) { - final l10n = ref.watch(l10nProvider); - final colors = context.colors; - final text = context.themeText; - final fmt = ref.watch(numberFormattingServiceProvider); - final approxDisplay = ref.watch(txAmountDisplayProvider)( - widget.amount, - isSend: true, - withSignPrefix: false, - withQuanSymbol: false, - quanDecimals: 4, - ); - final shortAddr = AddressFormattingService.formatAddress(widget.recipientAddress); - final multisigService = ref.watch(multisigServiceProvider); - final currentBlock = ref.watch(multisigCurrentBlockProvider).value; - - return ScaffoldBase( - appBar: V2AppBar(title: l10n.multisigProposeTitle), - mainContent: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _heroCard(l10n, colors, text, approxDisplay), - const SizedBox(height: 28), - Expanded(child: SingleChildScrollView(child: _summary(l10n, shortAddr, fmt, multisigService, currentBlock))), - if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Text(_errorMessage!, style: text.detail?.copyWith(color: colors.textError)), - ], - ], - ), - bottomContent: ScaffoldBaseBottomContent( - child: QuantusButton.simple( - label: l10n.multisigProposeCreateButton, - variant: ButtonVariant.primary, - isLoading: _submitting, - isDisabled: _submitting, - onTap: _submit, - ), - ), - ); - } - - Widget _heroCard(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text, CurrencyDisplayState approxDisplay) { - final labelStyle = text.receiveLabel?.copyWith(color: colors.textLabel); - - return SplitCard( - topChild: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.multisigProposeReviewProposing, style: labelStyle), - const SizedBox(height: 16), - AmountDisplayWithConversion( - amountDisplay: approxDisplay, - alignment: CrossAxisAlignment.start, - onFlip: _toggleFlip, - ), - ], - ), - bottomChild: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.sendReviewTo, style: labelStyle), - const SizedBox(height: 16), - AddressCheckphraseWithInitial( - recipientChecksum: widget.recipientChecksum, - recipientAddress: widget.recipientAddress, - ), - ], - ), - ); - } - - Widget _summary( - AppLocalizations l10n, - String shortAddr, - NumberFormattingService fmt, - MultisigService multisigService, - int? currentBlock, - ) { - final shownDecimals = AppConstants.decimals; - final rowSpacing = 4.0; - final fees = widget.feeBreakdown; - final valueStyle = context.themeText.transactionDetailRowLabel; - - String formatAmount(BigInt value) => - l10n.commonAmountBalance(fmt.formatBalance(value, smartDecimals: shownDecimals), AppConstants.tokenSymbol); - - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox(height: rowSpacing), - DetailSummaryRow.review(label: l10n.sendReviewTo, value: shortAddr, valueStyle: valueStyle), - SizedBox(height: rowSpacing), - DetailSummaryRow.review( - label: l10n.sendReviewAmount, - value: formatAmount(widget.amount), - valueStyle: valueStyle, - ), - SizedBox(height: rowSpacing), - DetailSummaryRow.review( - label: l10n.multisigProposeThresholdLabel, - value: '${widget.msig.threshold}/${widget.msig.signers.length}', - valueStyle: valueStyle, - ), - SizedBox(height: rowSpacing), - DetailSummaryRow.review( - label: l10n.multisigProposeExpiresLabel, - valueWidget: MultisigExpiryValue( - parts: resolveMultisigExpiryParts( - l10n: l10n, - expiryBlock: fees.expiryBlock, - multisigService: multisigService, - currentBlock: currentBlock, - ), - style: valueStyle, - ), - valueFlex: 4, - valueStyle: valueStyle, - ), - SizedBox(height: rowSpacing), - DetailSummaryRow.review( - label: l10n.sendReviewNetworkFee, - value: formatAmount(fees.networkFee), - valueStyle: valueStyle, - ), - SizedBox(height: rowSpacing), - DetailSummaryRow.review( - label: l10n.multisigProposalDepositLabel, - value: formatAmount(fees.deposit), - valueStyle: valueStyle, - ), - SizedBox(height: rowSpacing), - DetailSummaryRow.review( - label: l10n.multisigProposeFeeRowLabel, - value: formatAmount(fees.creationFee), - valueStyle: valueStyle, - ), - SizedBox(height: rowSpacing), - DetailSummaryRow.review( - label: l10n.multisigProposeMemberTotalLabel, - value: formatAmount(fees.memberCost), - valueStyle: valueStyle, - ), - SizedBox(height: rowSpacing), - ], - ); - } -} diff --git a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart index 6b94e35a..fac08737 100644 --- a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart @@ -11,8 +11,9 @@ import 'package:resonance_network_wallet/providers/pending_transactions_provider import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/pending_transaction_polling_service.dart'; import 'package:resonance_network_wallet/services/pos_service.dart'; -import 'package:resonance_network_wallet/shared/utils/open_external_url.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/shared/utils/url_utils.dart'; +import 'package:resonance_network_wallet/v2/components/explorer_link.dart'; import 'package:resonance_network_wallet/v2/components/loader.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/quantus_icon_button.dart'; @@ -153,12 +154,6 @@ class _PosQrScreenState extends ConsumerState { Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PosAmountScreen())); } - void _openExplorer() { - final txHash = _paidTransfer?.txHash; - if (txHash == null) return; - openUrl('${AppConstants.explorerEndpoint}/immediate-transactions/$txHash'); - } - @override Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); @@ -255,7 +250,9 @@ class _PosQrScreenState extends ConsumerState { const SizedBox(height: 32), _buildFromSection(l10n, colors, text, formattedAddress), const Spacer(), - _buildExplorerLink(l10n, colors, text), + ExplorerLink( + url: _paidTransfer?.txHash == null ? null : explorerImmediateTransactionUrl(_paidTransfer!.txHash), + ), const SizedBox(height: 16), ], ); @@ -310,19 +307,6 @@ class _PosQrScreenState extends ConsumerState { ); } - Widget _buildExplorerLink(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text) { - return GestureDetector( - onTap: _openExplorer, - child: Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colors.textTertiary, width: 1)), - ), - padding: const EdgeInsets.only(bottom: 3), - child: Text(l10n.activityDetailViewExplorer, style: text.smallParagraph?.copyWith(color: colors.textTertiary)), - ), - ); - } - Widget _buildQrContent( AppLocalizations l10n, PosPaymentRequest request, diff --git a/mobile-app/lib/v2/screens/send/input_amount_screen.dart b/mobile-app/lib/v2/screens/send/input_amount_screen.dart index f82cfc00..6fa7f94e 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/models/fiat_currency.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; @@ -12,8 +11,8 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/review_send_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_screen_logic.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -24,6 +23,7 @@ import 'package:resonance_network_wallet/v2/components/loader.dart'; import 'package:resonance_network_wallet/v2/components/quantus_icon_button.dart'; class InputAmountScreen extends ConsumerStatefulWidget { + final SendStrategy strategy; final String recipientAddress; final String? recipientChecksum; final String? initialAmount; @@ -31,6 +31,7 @@ class InputAmountScreen extends ConsumerStatefulWidget { const InputAmountScreen({ super.key, + required this.strategy, required this.recipientAddress, this.recipientChecksum, this.initialAmount, @@ -50,14 +51,13 @@ class _InputAmountScreenState extends ConsumerState { final _feeDebouncer = Debouncer(delay: const Duration(milliseconds: 500)); - static final BigInt _estimateFeeAmount = BigInt.from(1000) * NumberFormattingService.scaleFactorBigInt; - String? _recipientChecksum; BigInt _amount = BigInt.zero; - BigInt _networkFee = BigInt.zero; - int _blockHeight = 0; + SendFee? _fee; bool _isFetchingFee = false; bool _hasFee = false; + bool _feeFetchFailed = false; + int _feeFetchGeneration = 0; AmountInputLogic get _amountInputLogic => AmountInputLogic( exchangeRateService: ref.read(exchangeRateServiceProvider), @@ -137,46 +137,50 @@ class _InputAmountScreenState extends ConsumerState { } void _refreshFee() { - final recipient = widget.recipientAddress.trim(); - if (_amount > BigInt.zero && ref.read(substrateServiceProvider).isValidSS58Address(recipient)) { - _fetchFee(_amount, recipient); - } else { - _fetchEstimatedFee(); - } - } - - Future _fetchEstimatedFee() async { - final displayAccount = ref.read(activeAccountProvider).value; - if (displayAccount is! RegularAccount) return; - _fetchFee(_estimateFeeAmount, displayAccount.account.accountId); + final generation = ++_feeFetchGeneration; + final showLoader = !_hasFee || _feeFetchFailed; + setState(() { + _isFetchingFee = showLoader; + if (showLoader) _feeFetchFailed = false; + }); + _fetchFee(generation); } - Future _fetchFee(BigInt amount, String toAddress) async { - if (_isFetchingFee) return; - final displayAccount = ref.read(activeAccountProvider).value; - if (displayAccount is! RegularAccount) return; - _isFetchingFee = true; + Future _fetchFee(int generation) async { try { - final balancesService = ref.read(balancesServiceProvider); - final feeData = await balancesService.getBalanceTransferFee(displayAccount.account, toAddress, amount); - if (!mounted) return; + final fee = await widget.strategy.estimateFee(ref, recipient: widget.recipientAddress.trim(), amount: _amount); + if (!mounted || generation != _feeFetchGeneration) return; setState(() { - _networkFee = feeData.fee; - _blockHeight = feeData.blockNumber; + _fee = fee; _hasFee = true; + _feeFetchFailed = false; + _isFetchingFee = false; + }); + } catch (e, st) { + debugPrint('Fee fetch error: $e\n$st'); + if (!mounted || generation != _feeFetchGeneration) return; + setState(() { + _fee = null; + _hasFee = false; + _feeFetchFailed = true; + _isFetchingFee = false; }); - } catch (e) { - debugPrint('Fee fetch error: $e'); - } finally { - if (mounted) setState(() => _isFetchingFee = false); } } + void _retryFeeFetch() { + _feeDebouncer.cancel(); + _refreshFee(); + } + /// Converts a raw QUAN [BigInt] to a fiat input string using the current /// exchange rate and selected fiat currency, formatted for the user's locale. void _setMax() { - final balance = ref.read(effectiveMaxBalanceProvider).value ?? BigInt.zero; - final max = SendScreenLogic.calculateMaxSendableAmount(balance: balance, networkFee: _networkFee); + final spendable = ref.read(widget.strategy.spendableBalanceProvider).value ?? BigInt.zero; + final max = SendScreenLogic.calculateMaxSendableAmount( + balance: spendable, + networkFee: widget.strategy.feeChargedToBalance(_fee), + ); final isFlipped = ref.read(isCurrencyFlippedProvider); _amountController.text = isFlipped ? _amountInputLogic.quanToFiatString(max) @@ -197,10 +201,10 @@ class _InputAmountScreenState extends ConsumerState { }); } - Future _openReview() async { - if (_recipientChecksum == null) { - final l10n = ref.read(l10nProvider); - context.showErrorToaster(message: l10n.sendInputAmountChecksumRequired); + void _openReview() { + final fee = _fee; + if (_recipientChecksum == null || fee == null) { + context.showErrorToaster(message: ref.read(l10nProvider).sendInputAmountChecksumRequired); return; } @@ -209,10 +213,10 @@ class _InputAmountScreenState extends ConsumerState { context, MaterialPageRoute( builder: (_) => ReviewSendScreen( + strategy: widget.strategy, recipientAddress: widget.recipientAddress, amount: _amount, - networkFee: _networkFee, - blockHeight: _blockHeight, + fee: fee, recipientChecksum: _recipientChecksum!, isPayMode: widget.isPayMode, ), @@ -223,36 +227,50 @@ class _InputAmountScreenState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); - ref.watch(activeAccountProvider); + final strings = widget.strategy.strings(l10n); final colors = context.colors; final text = context.themeText; - final balance = ref.watch(effectiveMaxBalanceProvider); - final activeId = ref.watch(activeAccountProvider).value?.account.accountId ?? ''; + final balance = ref.watch(widget.strategy.spendableBalanceProvider); + final sourceId = widget.strategy.sourceAccountId(ref) ?? ''; final recipient = widget.recipientAddress.trim(); final formattingService = ref.read(numberFormattingServiceProvider); + final fee = _fee; - final amountStatus = SendScreenLogic.getAmountStatus(_amount, balance.value ?? BigInt.zero, _networkFee); + final amountStatus = SendScreenLogic.getAmountStatus( + _amount, + balance.value ?? BigInt.zero, + widget.strategy.feeChargedToBalance(fee), + ); + final affordabilityError = fee == null ? null : widget.strategy.affordabilityError(ref, fee, l10n); final btnDisabled = !_hasFee || + _feeFetchFailed || _recipientChecksum == null || + balance.isLoading || + widget.strategy.extraBalancesLoading(ref) || + affordabilityError != null || SendScreenLogic.isButtonDisabled( hasAddressError: false, amountStatus: amountStatus, recipientText: recipient, - activeAccountId: activeId, + activeAccountId: sourceId, ); - final btnText = SendScreenLogic.getButtonText( - l10n: l10n, - hasAddressError: false, - amountStatus: amountStatus, - recipientText: recipient, - amount: _amount, - activeAccountId: activeId, - formattingService: formattingService, - ); + final btnText = + affordabilityError ?? + (amountStatus == AmountStatus.valid + ? strings.reviewButtonLabel + : SendScreenLogic.getButtonText( + l10n: l10n, + hasAddressError: false, + amountStatus: amountStatus, + recipientText: recipient, + amount: _amount, + activeAccountId: sourceId, + formattingService: formattingService, + )); return ScaffoldBase( - appBar: V2AppBar(title: widget.isPayMode ? l10n.sendPayTitle : l10n.sendTitle), + appBar: V2AppBar(title: widget.isPayMode ? l10n.sendPayTitle : strings.flowTitle), mainContent: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( controller: _scrollController, @@ -262,7 +280,7 @@ class _InputAmountScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _recipientCard(colors, text, l10n), + _recipientCard(colors, text, strings), const SizedBox(height: 32), _amountCenter(colors, text), const SizedBox(height: 32), @@ -272,11 +290,11 @@ class _InputAmountScreenState extends ConsumerState { ), ), ), - bottomContent: _bottomSection(colors, text, l10n, btnText, balance, btnDisabled), + bottomContent: _bottomSection(colors, text, l10n, strings, btnText, balance, btnDisabled), ); } - Widget _recipientCard(AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n) { + Widget _recipientCard(AppColorsV2 colors, AppTextTheme text, SendStrings strings) { final addr = widget.recipientAddress.trim(); final shortAddr = AddressFormattingService.formatAddress(addr); @@ -291,7 +309,7 @@ class _InputAmountScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.sendInputAmountSendTo, + strings.amountRecipientCardLabel, style: context.themeText.receiveLabel?.copyWith(color: colors.textLabel), ), const SizedBox(height: 16), @@ -424,10 +442,57 @@ class _InputAmountScreenState extends ConsumerState { ); } + Widget _feeValue( + AppColorsV2 colors, + AppTextTheme text, + AppLocalizations l10n, + SendStrings strings, + NumberFormattingService fmt, + ) { + if (_isFetchingFee) { + return const Align(alignment: Alignment.centerRight, child: Loader()); + } + if (_feeFetchFailed) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + strings.feeFetchFailedMessage, + style: text.smallParagraph?.copyWith(color: colors.error), + textAlign: TextAlign.right, + ), + const SizedBox(height: 4), + IntrinsicWidth( + child: QuantusButton.simple( + label: l10n.homeActivityRetry, + onTap: _retryFeeFetch, + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), + variant: ButtonVariant.transparent, + textStyle: text.smallParagraph?.copyWith( + color: colors.accentOrange, + decoration: TextDecoration.underline, + decorationColor: colors.accentOrange, + ), + ), + ), + ], + ); + } + final fee = _fee; + if (_hasFee && fee != null) { + return Text( + l10n.commonAmountBalance(fmt.formatBalance(fee.displayFee, smartDecimals: 5), AppConstants.tokenSymbol), + style: text.smallParagraph?.copyWith(color: colors.textTertiary), + ); + } + return const Align(alignment: Alignment.centerRight, child: Loader()); + } + Widget _bottomSection( AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n, + SendStrings strings, String btnText, AsyncValue balance, bool btnDisabled, @@ -470,21 +535,9 @@ class _InputAmountScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - l10n.sendInputAmountNetworkFee, - style: text.smallParagraph?.copyWith(color: colors.textTertiary), - ), + Text(strings.feeLabel, style: text.smallParagraph?.copyWith(color: colors.textTertiary)), const SizedBox(height: 4), - if (_hasFee) - Text( - l10n.commonAmountBalance( - formattingService.formatBalance(_networkFee, smartDecimals: 5), - AppConstants.tokenSymbol, - ), - style: text.smallParagraph?.copyWith(color: colors.textTertiary), - ) - else - const Loader(), + _feeValue(colors, text, l10n, strings, formattingService), ], ), ), diff --git a/mobile-app/lib/v2/screens/send/keystone_scan_signature_screen.dart b/mobile-app/lib/v2/screens/send/keystone_scan_signature_screen.dart index c37b9641..6260c77f 100644 --- a/mobile-app/lib/v2/screens/send/keystone_scan_signature_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_scan_signature_screen.dart @@ -9,9 +9,11 @@ import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/shared/utils/url_utils.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/quantus_icon_button.dart'; -import 'package:resonance_network_wallet/v2/screens/send/tx_submitted_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_terminal_screen.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -27,6 +29,7 @@ class KeystoneScanSignatureScreen extends ConsumerStatefulWidget { final int blockHeight; final String recipientChecksum; final bool isPayMode; + final SendTerminalContent terminal; const KeystoneScanSignatureScreen({ super.key, @@ -37,6 +40,7 @@ class KeystoneScanSignatureScreen extends ConsumerStatefulWidget { required this.networkFee, required this.blockHeight, required this.recipientChecksum, + required this.terminal, this.isPayMode = false, }); @@ -97,7 +101,7 @@ class _KeystoneScanSignatureScreenState extends ConsumerState TxSubmittedScreen( - amount: widget.amount, - recipientAddress: widget.recipientAddress, - recipientChecksum: widget.recipientChecksum, - isPayMode: widget.isPayMode, - ), + builder: (_) => + SendTerminalScreen(content: widget.terminal.copyWith(explorerUrl: explorerImmediateTransactionUrl(hash))), ), ); } catch (e, st) { diff --git a/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart b/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart index 273ec189..5c56c169 100644 --- a/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart @@ -12,6 +12,7 @@ import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_scan_signature_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -26,6 +27,7 @@ class KeystoneSignScreen extends ConsumerStatefulWidget { final int blockHeight; final String recipientChecksum; final bool isPayMode; + final SendTerminalContent terminal; const KeystoneSignScreen({ super.key, @@ -35,6 +37,7 @@ class KeystoneSignScreen extends ConsumerStatefulWidget { required this.networkFee, required this.blockHeight, required this.recipientChecksum, + required this.terminal, this.isPayMode = false, }); @@ -88,6 +91,7 @@ class _KeystoneSignScreenState extends ConsumerState { blockHeight: widget.blockHeight, recipientChecksum: widget.recipientChecksum, isPayMode: widget.isPayMode, + terminal: widget.terminal, ), ), ); diff --git a/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart b/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart new file mode 100644 index 00000000..2740acf1 --- /dev/null +++ b/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/multisig_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/services/local_auth_service.dart'; +import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; +import 'package:resonance_network_wallet/v2/components/detail_summary_row.dart'; +import 'package:resonance_network_wallet/v2/components/multisig_expiry_value.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +/// Proposes a transfer from a multisig account. The multisig is a view-only +/// account, so funds leave from [msig] while the proposing member pays the fee. +class MultisigProposeStrategy extends SendStrategy { + final MultisigAccount msig; + + const MultisigProposeStrategy({required this.msig}); + + static final BigInt _estimateFeeAmount = BigInt.from(1000) * NumberFormattingService.scaleFactorBigInt; + + @override + String? sourceAccountId(WidgetRef ref) => msig.accountId; + + @override + SendStrings strings(AppLocalizations l10n) => SendStrings( + flowTitle: l10n.multisigProposeTitle, + recipientSectionLabel: l10n.multisigProposeSelectRecipientTo, + amountRecipientCardLabel: l10n.multisigProposeAmountToLabel, + feeLabel: l10n.multisigProposeFeeLabel, + feeFetchFailedMessage: l10n.multisigProposeFeeFetchFailed, + reviewButtonLabel: l10n.multisigProposeReviewButton, + reviewHeroLabel: l10n.multisigProposeReviewProposing, + reviewConfirmLabel: l10n.multisigProposeCreateButton, + ); + + @override + ProviderListenable> get spendableBalanceProvider => balanceProviderFamily(msig.accountId); + + @override + bool extraBalancesLoading(WidgetRef ref) => ref.watch(effectiveBalanceProviderFamily(msig.myMemberAccountId)).isLoading; + + // The proposal fee is paid by the member, not from the multisig balance. + @override + BigInt feeChargedToBalance(SendFee? fee) => BigInt.zero; + + @override + Future estimateFee(WidgetRef ref, {required String recipient, required BigInt amount}) async { + final service = ref.read(multisigServiceProvider); + final useReal = amount > BigInt.zero && ref.read(substrateServiceProvider).isValidSS58Address(recipient); + final feeAmount = useReal ? amount : _estimateFeeAmount; + + final accounts = ref.read(accountsProvider).value; + Account? signer; + if (accounts != null) { + for (final account in accounts) { + if (account.accountId == msig.myMemberAccountId) { + signer = account; + break; + } + } + } + + final ProposeFeeBreakdown breakdown; + if (signer != null) { + breakdown = await service.estimateProposeFeeBreakdown( + msig: msig, + signer: signer, + recipient: recipient.trim(), + amount: feeAmount, + ); + } else { + final currentBlock = await service.currentBlockNumber(); + breakdown = ProposeFeeBreakdown( + networkFee: BigInt.zero, + deposit: service.proposalDeposit, + creationFee: service.proposalCreationFee(msig.signers.length), + expiryBlock: currentBlock + service.blocksForDuration(MultisigService.defaultProposalExpiry), + ); + } + return ProposeFee(breakdown); + } + + @override + String? affordabilityError(WidgetRef ref, SendFee fee, AppLocalizations l10n) { + final memberBalance = ref.watch(effectiveBalanceProviderFamily(msig.myMemberAccountId)).value; + if (memberBalance == null) return null; + return memberBalance < fee.displayFee ? l10n.sendLogicInsufficientBalance : null; + } + + @override + List reviewRows( + BuildContext context, + WidgetRef ref, { + required String recipientAddress, + required BigInt amount, + required SendFee fee, + }) { + final l10n = ref.watch(l10nProvider); + final fmt = ref.watch(numberFormattingServiceProvider); + final multisigService = ref.watch(multisigServiceProvider); + final currentBlock = ref.watch(multisigCurrentBlockProvider).value; + final breakdown = (fee as ProposeFee).breakdown; + final valueStyle = context.themeText.transactionDetailRowLabel; + final addr = AddressFormattingService.formatAddress(recipientAddress); + + String amt(BigInt v) => + l10n.commonAmountBalance(fmt.formatBalance(v, smartDecimals: AppConstants.decimals), AppConstants.tokenSymbol); + + return [ + const SizedBox(height: 4), + DetailSummaryRow.review(label: l10n.sendReviewTo, value: addr, valueStyle: valueStyle), + const SizedBox(height: 4), + DetailSummaryRow.review(label: l10n.sendReviewAmount, value: amt(amount), valueStyle: valueStyle), + const SizedBox(height: 4), + DetailSummaryRow.review( + label: l10n.multisigProposeThresholdLabel, + value: '${msig.threshold}/${msig.signers.length}', + valueStyle: valueStyle, + ), + const SizedBox(height: 4), + DetailSummaryRow.review( + label: l10n.multisigProposeExpiresLabel, + valueWidget: MultisigExpiryValue( + parts: resolveMultisigExpiryParts( + l10n: l10n, + expiryBlock: breakdown.expiryBlock, + multisigService: multisigService, + currentBlock: currentBlock, + ), + style: valueStyle, + ), + valueFlex: 4, + valueStyle: valueStyle, + ), + const SizedBox(height: 4), + DetailSummaryRow.review(label: l10n.sendReviewNetworkFee, value: amt(breakdown.networkFee), valueStyle: valueStyle), + const SizedBox(height: 4), + DetailSummaryRow.review(label: l10n.multisigProposalDepositLabel, value: amt(breakdown.deposit), valueStyle: valueStyle), + const SizedBox(height: 4), + DetailSummaryRow.review(label: l10n.multisigProposeFeeRowLabel, value: amt(breakdown.creationFee), valueStyle: valueStyle), + const SizedBox(height: 4), + DetailSummaryRow.review( + label: l10n.multisigProposeMemberTotalLabel, + value: amt(breakdown.memberCost), + valueStyle: valueStyle, + ), + const SizedBox(height: 4), + ]; + } + + @override + Future submit( + WidgetRef ref, { + required String recipientAddress, + required String recipientChecksum, + required BigInt amount, + required SendFee fee, + required bool isPayMode, + }) async { + final l10n = ref.read(l10nProvider); + final fmt = ref.read(numberFormattingServiceProvider); + final breakdown = (fee as ProposeFee).breakdown; + + final authed = await LocalAuthService().authenticate(localizedReason: l10n.multisigProposeAuthReason); + if (!authed) return SendFailed(l10n.multisigProposeAuthRequired); + + try { + final signer = ref + .read(accountsProvider) + .value + ?.firstWhere( + (a) => a.accountId == msig.myMemberAccountId, + orElse: () => throw Exception('Member account not found in local wallet'), + ); + if (signer == null) throw Exception('No signer account available'); + + await ref + .read(transactionSubmissionServiceProvider) + .proposeTransfer( + msig: msig, + signer: signer, + recipient: recipientAddress, + amount: amount, + expiryBlock: breakdown.expiryBlock, + feeBreakdown: breakdown, + ); + + unawaited( + RecentAddressesService() + .addAddress(recipientAddress.trim()) + .catchError((Object e) => debugPrint('Failed to save recent address: $e')), + ); + + ref.invalidate(multisigOpenProposalsProvider(msig)); + ref.invalidate(multisigPastProposalsProvider(msig)); + ref.invalidate(multisigCurrentBlockProvider); + + return SendSubmitted(_terminal(l10n, fmt, recipient: recipientAddress, checksum: recipientChecksum, amount: amount)); + } catch (e, st) { + debugPrint('Propose submit error: $e $st'); + return SendFailed(l10n.multisigProposeSubmitFailed); + } + } + + SendTerminalContent _terminal( + AppLocalizations l10n, + NumberFormattingService fmt, { + required String recipient, + required String checksum, + required BigInt amount, + }) { + return SendTerminalContent( + title: l10n.multisigProposeTitle, + headline: l10n.multisigProposeDoneHeadline, + subline: l10n.multisigProposeDoneSubline, + amountText: l10n.commonAmountBalance(fmt.formatBalance(amount, smartDecimals: 4), AppConstants.tokenSymbol), + recipientAddress: recipient, + recipientChecksum: checksum, + signaturesLabel: l10n.multisigSignaturesCount(1, msig.threshold), + doneLabel: l10n.multisigDone, + ); + } +} diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart new file mode 100644 index 00000000..5a835eac --- /dev/null +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/services/local_auth_service.dart'; +import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; +import 'package:resonance_network_wallet/shared/utils/url_utils.dart'; +import 'package:resonance_network_wallet/v2/components/detail_summary_row.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +/// Standard single-signer transfer from the active account. Signs locally, or +/// hands off to the Keystone QR flow for hardware accounts. +class RegularSendStrategy extends SendStrategy { + const RegularSendStrategy(); + + static final BigInt _estimateFeeAmount = BigInt.from(1000) * NumberFormattingService.scaleFactorBigInt; + + @override + String? sourceAccountId(WidgetRef ref) => ref.read(activeAccountProvider).value?.account.accountId; + + @override + SendStrings strings(AppLocalizations l10n) => SendStrings( + flowTitle: l10n.sendTitle, + recipientSectionLabel: l10n.sendSelectRecipientSendTo, + amountRecipientCardLabel: l10n.sendInputAmountSendTo, + feeLabel: l10n.sendInputAmountNetworkFee, + feeFetchFailedMessage: l10n.multisigProposeFeeFetchFailed, + reviewButtonLabel: l10n.sendLogicReviewSend, + reviewHeroLabel: l10n.sendReviewSending, + reviewConfirmLabel: l10n.sendReviewConfirm, + ); + + @override + ProviderListenable> get spendableBalanceProvider => effectiveMaxBalanceProvider; + + @override + bool extraBalancesLoading(WidgetRef ref) => false; + + @override + BigInt feeChargedToBalance(SendFee? fee) => (fee as RegularFee?)?.networkFee ?? BigInt.zero; + + @override + Future estimateFee(WidgetRef ref, {required String recipient, required BigInt amount}) async { + final displayAccount = ref.read(activeAccountProvider).value; + if (displayAccount is! RegularAccount) { + throw StateError('Regular send requires an active regular account'); + } + final account = displayAccount.account; + final useReal = amount > BigInt.zero && ref.read(substrateServiceProvider).isValidSS58Address(recipient); + final feeAmount = useReal ? amount : _estimateFeeAmount; + final toAddress = useReal ? recipient : account.accountId; + final feeData = await ref.read(balancesServiceProvider).getBalanceTransferFee(account, toAddress, feeAmount); + return RegularFee(networkFee: feeData.fee, blockHeight: feeData.blockNumber); + } + + @override + String? affordabilityError(WidgetRef ref, SendFee fee, AppLocalizations l10n) => null; + + @override + List reviewRows( + BuildContext context, + WidgetRef ref, { + required String recipientAddress, + required BigInt amount, + required SendFee fee, + }) { + final l10n = ref.watch(l10nProvider); + final fmt = ref.watch(numberFormattingServiceProvider); + final networkFee = (fee as RegularFee).networkFee; + final valueStyle = context.themeText.transactionDetailRowLabel; + final addr = recipientAddress.trim(); + + String amt(BigInt v) => + l10n.commonAmountBalance(fmt.formatBalance(v, smartDecimals: AppConstants.decimals), AppConstants.tokenSymbol); + + return [ + const SizedBox(height: 7), + DetailSummaryRow.review(label: l10n.sendReviewTo, value: addr, valueStyle: valueStyle), + const SizedBox(height: 7), + DetailSummaryRow.review(label: l10n.sendReviewAmount, value: amt(amount), valueStyle: valueStyle), + const SizedBox(height: 7), + DetailSummaryRow.review(label: l10n.sendReviewNetworkFee, value: amt(networkFee), valueStyle: valueStyle), + const SizedBox(height: 7), + DetailSummaryRow.review(label: l10n.sendReviewYouPay, value: amt(amount + networkFee), valueStyle: valueStyle), + const SizedBox(height: 7), + ]; + } + + @override + Future submit( + WidgetRef ref, { + required String recipientAddress, + required String recipientChecksum, + required BigInt amount, + required SendFee fee, + required bool isPayMode, + }) async { + final l10n = ref.read(l10nProvider); + final fmt = ref.read(numberFormattingServiceProvider); + final regularFee = fee as RegularFee; + final recipient = recipientAddress.trim(); + final account = (await SettingsService().getActiveRegularAccount())!; + final terminal = _terminal(l10n, fmt, recipient: recipient, checksum: recipientChecksum, amount: amount, isPayMode: isPayMode); + + // Keystone (hardware) accounts sign off-device: hand off to the QR flow + // instead of signing locally. The debug flag forces this path for testing. + if (account.accountType == AccountType.keystone || AppConstants.debugHardwareWallet) { + return SendNeedsHardwareSignature( + account: account, + networkFee: regularFee.networkFee, + blockHeight: regularFee.blockHeight, + terminal: terminal, + ); + } + + final authed = await LocalAuthService().authenticate(localizedReason: l10n.sendReviewAuthReason); + if (!authed) return SendFailed(l10n.sendReviewAuthRequired); + + try { + final hash = await ref + .read(transactionSubmissionServiceProvider) + .balanceTransfer(account, recipient, amount, regularFee.networkFee, regularFee.blockHeight); + unawaited( + RecentAddressesService() + .addAddress(recipient) + .catchError((Object e) => debugPrint('Failed to save recent address: $e')), + ); + return SendSubmitted(terminal.copyWith(explorerUrl: explorerImmediateTransactionUrl(hash))); + } catch (e) { + debugPrint('Transfer failed: $e'); + return SendFailed(l10n.sendReviewSubmitFailed); + } + } + + SendTerminalContent _terminal( + AppLocalizations l10n, + NumberFormattingService fmt, { + required String recipient, + required String checksum, + required BigInt amount, + required bool isPayMode, + }) { + final n = fmt.formatBalance(amount, smartDecimals: 4); + return SendTerminalContent( + title: isPayMode ? l10n.sendPayTitle : l10n.sendTitle, + headline: isPayMode + ? l10n.sendTxSubmittedHeadlinePaid(n, AppConstants.tokenSymbol) + : l10n.sendTxSubmittedHeadlineSent(n, AppConstants.tokenSymbol), + subline: l10n.sendTxSubmittedOnItsWay, + recipientAddress: recipient, + recipientChecksum: checksum, + doneLabel: l10n.sendTxSubmittedDone, + topSpacing: 70, + ); + } +} diff --git a/mobile-app/lib/v2/screens/send/review_send_screen.dart b/mobile-app/lib/v2/screens/send/review_send_screen.dart index fa7e69d7..b71980e4 100644 --- a/mobile-app/lib/v2/screens/send/review_send_screen.dart +++ b/mobile-app/lib/v2/screens/send/review_send_screen.dart @@ -1,15 +1,8 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; -import 'package:resonance_network_wallet/services/local_auth_service.dart'; -import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; import 'package:resonance_network_wallet/v2/components/address_checkphrase_with_initial.dart'; import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; @@ -18,24 +11,25 @@ import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_cont import 'package:resonance_network_wallet/v2/components/split_card.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/send/tx_submitted_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_terminal_screen.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; class ReviewSendScreen extends ConsumerStatefulWidget { + final SendStrategy strategy; final String recipientAddress; final BigInt amount; - final BigInt networkFee; - final int blockHeight; + final SendFee fee; final String recipientChecksum; final bool isPayMode; const ReviewSendScreen({ super.key, + required this.strategy, required this.recipientAddress, required this.amount, - required this.networkFee, - required this.blockHeight, + required this.fee, required this.recipientChecksum, this.isPayMode = false, }); @@ -58,91 +52,54 @@ class _ReviewSendScreenState extends ConsumerState { _errorMessage = null; }); - final l10n = ref.read(l10nProvider); - final settings = SettingsService(); - final account = (await settings.getActiveRegularAccount())!; + final outcome = await widget.strategy.submit( + ref, + recipientAddress: widget.recipientAddress.trim(), + recipientChecksum: widget.recipientChecksum, + amount: widget.amount, + fee: widget.fee, + isPayMode: widget.isPayMode, + ); if (!mounted) return; - // Keystone (hardware) accounts sign off-device: hand off to the QR flow - // instead of signing locally. The debug flag forces this path for testing. - if (account.accountType == AccountType.keystone || AppConstants.debugHardwareWallet) { - setState(() => _submitting = false); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => KeystoneSignScreen( - account: account, - recipientAddress: widget.recipientAddress.trim(), - amount: widget.amount, - networkFee: widget.networkFee, - blockHeight: widget.blockHeight, - recipientChecksum: widget.recipientChecksum, - isPayMode: widget.isPayMode, - ), - ), - ); - return; - } - - final authed = await LocalAuthService().authenticate(localizedReason: l10n.sendReviewAuthReason); - if (!authed || !mounted) { - setState(() { - _submitting = false; - _errorMessage = l10n.sendReviewAuthRequired; - }); - return; - } - - try { - final submissionService = ref.read(transactionSubmissionServiceProvider); - await submissionService.balanceTransfer( - account, - widget.recipientAddress.trim(), - widget.amount, - widget.networkFee, - widget.blockHeight, - ); - unawaited( - RecentAddressesService() - .addAddress(widget.recipientAddress.trim()) - .catchError((Object e) => debugPrint('Failed to save recent address: $e')), - ); - if (!mounted) return; - setState(() { - _submitting = false; - _errorMessage = null; - }); - - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => TxSubmittedScreen( - amount: widget.amount, - recipientAddress: widget.recipientAddress, - recipientChecksum: widget.recipientChecksum, - isPayMode: widget.isPayMode, + switch (outcome) { + case SendSubmitted(:final terminal): + setState(() { + _submitting = false; + _errorMessage = null; + }); + Navigator.push(context, MaterialPageRoute(builder: (_) => SendTerminalScreen(content: terminal))); + case SendNeedsHardwareSignature(:final account, :final networkFee, :final blockHeight, :final terminal): + setState(() => _submitting = false); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => KeystoneSignScreen( + account: account, + recipientAddress: widget.recipientAddress.trim(), + amount: widget.amount, + networkFee: networkFee, + blockHeight: blockHeight, + recipientChecksum: widget.recipientChecksum, + isPayMode: widget.isPayMode, + terminal: terminal, + ), ), - ), - ); - } catch (e) { - debugPrint('Transfer failed: $e'); - - if (mounted) { + ); + case SendFailed(:final message): setState(() { _submitting = false; - _errorMessage = ref.read(l10nProvider).sendReviewSubmitFailed; + _errorMessage = message; }); - } } } @override Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); - ref.watch(activeAccountProvider); + final strings = widget.strategy.strings(l10n); final colors = context.colors; final text = context.themeText; - final addr = widget.recipientAddress.trim(); final approxDisplay = ref.watch(txAmountDisplayProvider)( widget.amount, isSend: true, @@ -150,30 +107,37 @@ class _ReviewSendScreenState extends ConsumerState { withQuanSymbol: false, quanDecimals: 4, ); - final totalRaw = widget.amount + widget.networkFee; return ScaffoldBase( - appBar: V2AppBar(title: widget.isPayMode ? l10n.sendPayTitle : l10n.sendTitle), + appBar: V2AppBar(title: widget.isPayMode ? l10n.sendPayTitle : strings.flowTitle), mainContent: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _heroCard(colors, text, l10n, approxDisplay), - const SizedBox(height: 28), - _summarySection(l10n, addr, totalRaw), - if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Text(_errorMessage!, style: text.detail?.copyWith(color: colors.textError)), - ], - ], + _heroCard(colors, text, l10n, strings, approxDisplay), + const SizedBox(height: 28), + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: widget.strategy.reviewRows( + context, + ref, + recipientAddress: widget.recipientAddress, + amount: widget.amount, + fee: widget.fee, + ), + ), + ), ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Text(_errorMessage!, style: text.detail?.copyWith(color: colors.textError)), + ], ], ), bottomContent: ScaffoldBaseBottomContent( child: QuantusButton.simple( - label: l10n.sendReviewConfirm, + label: strings.reviewConfirmLabel, variant: ButtonVariant.primary, isLoading: _submitting, isDisabled: _submitting, @@ -183,14 +147,20 @@ class _ReviewSendScreenState extends ConsumerState { ); } - Widget _heroCard(AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n, CurrencyDisplayState approxDisplay) { + Widget _heroCard( + AppColorsV2 colors, + AppTextTheme text, + AppLocalizations l10n, + SendStrings strings, + CurrencyDisplayState approxDisplay, + ) { final sectionLabelStyle = text.receiveLabel?.copyWith(color: colors.textLabel); return SplitCard( topChild: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.sendReviewSending, style: sectionLabelStyle), + Text(strings.reviewHeroLabel, style: sectionLabelStyle), const SizedBox(height: 16), AmountDisplayWithConversion( amountDisplay: approxDisplay, @@ -212,59 +182,4 @@ class _ReviewSendScreenState extends ConsumerState { ), ); } - - Widget _summarySection(AppLocalizations l10n, String addr, BigInt totalRaw) { - final shownDecimals = AppConstants.decimals; - final formattingService = ref.watch(numberFormattingServiceProvider); - - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(height: 7), - _summaryRow(label: l10n.sendReviewTo, value: addr), - const SizedBox(height: 7), - _summaryRow( - label: l10n.sendReviewAmount, - value: l10n.commonAmountBalance( - formattingService.formatBalance(widget.amount, smartDecimals: shownDecimals), - AppConstants.tokenSymbol, - ), - ), - const SizedBox(height: 7), - _summaryRow( - label: l10n.sendReviewNetworkFee, - value: l10n.commonAmountBalance( - formattingService.formatBalance(widget.networkFee, smartDecimals: shownDecimals), - AppConstants.tokenSymbol, - ), - ), - const SizedBox(height: 7), - _summaryRow( - label: l10n.sendReviewYouPay, - value: l10n.commonAmountBalance( - formattingService.formatBalance(totalRaw, smartDecimals: shownDecimals), - AppConstants.tokenSymbol, - ), - ), - const SizedBox(height: 7), - ], - ); - } - - Widget _summaryRow({required String label, required String value}) { - final labelStyle = context.themeText.transactionDetailRowLabel?.copyWith(color: context.colors.textTertiary); - final valueStyle = context.themeText.transactionDetailRowLabel; - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: Text(label, style: labelStyle)), - const SizedBox(width: 8), - Flexible( - child: Text(value, style: valueStyle, textAlign: TextAlign.right), - ), - ], - ); - } } diff --git a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart index 4a6b1528..5933c826 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/dotted_border.dart'; import 'package:resonance_network_wallet/features/components/skeleton.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; @@ -19,11 +18,14 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; class SelectRecipientScreen extends ConsumerStatefulWidget { - const SelectRecipientScreen({super.key}); + final SendStrategy strategy; + + const SelectRecipientScreen({super.key, required this.strategy}); @override ConsumerState createState() => _SelectRecipientScreenState(); @@ -39,6 +41,7 @@ class _SelectRecipientScreenState extends ConsumerState { bool _hasAddressError = true; bool _loadingRecents = true; bool _isPayMode = false; + bool _canContinue = false; String? _recipientChecksum; @override @@ -59,13 +62,11 @@ class _SelectRecipientScreenState extends ConsumerState { Future _loadRecents() async { final checksumService = ref.read(humanReadableChecksumServiceProvider); - final settingsService = ref.read(settingsServiceProvider); final recentAddressesService = ref.read(recentAddressesServiceProvider); try { final all = await recentAddressesService.getAddresses(); - final active = await settingsService.getActiveAccount(); - final currentId = active?.account.accountId; + final currentId = widget.strategy.sourceAccountId(ref); final addresses = all.where((a) => a != currentId).toList(); if (!mounted) return; setState(() { @@ -91,6 +92,7 @@ class _SelectRecipientScreenState extends ConsumerState { _hasAddressError = true; _recipientChecksum = null; _isPayMode = false; + _canContinue = false; }); return; } @@ -101,9 +103,11 @@ class _SelectRecipientScreenState extends ConsumerState { final checksumService = ref.read(humanReadableChecksumServiceProvider); final substrate = ref.read(substrateServiceProvider); final isValid = substrate.isValidSS58Address(address); + final sourceId = widget.strategy.sourceAccountId(ref); setState(() { _hasAddressError = !isValid; _recipientChecksum = null; + _canContinue = isValid && address != sourceId; }); if (isValid) { checksumService.getHumanReadableName(address).then((checksum) { @@ -112,15 +116,6 @@ class _SelectRecipientScreenState extends ConsumerState { } } - bool get _canContinue { - final text = _recipientController.text.trim(); - if (text.isEmpty) return false; - if (_hasAddressError) return false; - final activeId = ref.read(activeAccountProvider).value?.account.accountId ?? ''; - if (text == activeId) return false; - return true; - } - /// Single entry point for every way a recipient is supplied (scan, paste, /// recent). The controller text is assigned last and outside [setState] so the /// [_onRecipientChanged] listener drives validation and the continue button. @@ -159,6 +154,7 @@ class _SelectRecipientScreenState extends ConsumerState { MaterialPageRoute( settings: inputAmountScreenRouteSettings, builder: (_) => InputAmountScreen( + strategy: widget.strategy, recipientAddress: address, recipientChecksum: _recipientChecksum, initialAmount: _amountController.text, @@ -174,6 +170,7 @@ class _SelectRecipientScreenState extends ConsumerState { setState(() { _recipientChecksum = null; _hasAddressError = true; + _canContinue = false; }); }); } @@ -190,19 +187,19 @@ class _SelectRecipientScreenState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); - ref.watch(activeAccountProvider); + final strings = widget.strategy.strings(l10n); final colors = context.colors; final text = context.themeText; return ScaffoldBase( - appBar: V2AppBar(title: l10n.sendTitle), + appBar: V2AppBar(title: strings.flowTitle), mainContent: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(l10n.sendSelectRecipientSendTo, style: text.sendSectionLabel?.copyWith(color: colors.textPrimary)), + Text(strings.recipientSectionLabel, style: text.sendSectionLabel?.copyWith(color: colors.textPrimary)), const SizedBox(height: 12), _buildRecipientField(colors, l10n), const SizedBox(height: 28), diff --git a/mobile-app/lib/v2/screens/send/send_strategy.dart b/mobile-app/lib/v2/screens/send/send_strategy.dart new file mode 100644 index 00000000..82f63097 --- /dev/null +++ b/mobile-app/lib/v2/screens/send/send_strategy.dart @@ -0,0 +1,188 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; + +/// Per-step labels that differ between the send flows (regular transfer vs +/// multisig proposal). Built once from [AppLocalizations] by each strategy so +/// the shared screens never branch on the flow type. +class SendStrings { + final String flowTitle; + final String recipientSectionLabel; + final String amountRecipientCardLabel; + final String feeLabel; + final String feeFetchFailedMessage; + final String reviewButtonLabel; + final String reviewHeroLabel; + final String reviewConfirmLabel; + + const SendStrings({ + required this.flowTitle, + required this.recipientSectionLabel, + required this.amountRecipientCardLabel, + required this.feeLabel, + required this.feeFetchFailedMessage, + required this.reviewButtonLabel, + required this.reviewHeroLabel, + required this.reviewConfirmLabel, + }); +} + +/// Fee for a send. The shared screens only read [displayFee]; each strategy +/// keeps its concrete payload for submission. +sealed class SendFee { + const SendFee(); + + BigInt get displayFee; +} + +class RegularFee extends SendFee { + final BigInt networkFee; + final int blockHeight; + + const RegularFee({required this.networkFee, required this.blockHeight}); + + @override + BigInt get displayFee => networkFee; +} + +class ProposeFee extends SendFee { + final ProposeFeeBreakdown breakdown; + + const ProposeFee(this.breakdown); + + @override + BigInt get displayFee => breakdown.memberCost; +} + +/// Content for the shared terminal (success) screen. All strings are resolved +/// up front so it can be built without a [BuildContext]. +class SendTerminalContent { + final String title; + final String headline; + final String subline; + final String? amountText; + final String recipientAddress; + final String? recipientChecksum; + final String? signaturesLabel; + final String doneLabel; + final double topSpacing; + + /// Block-explorer URL for the submitted transaction. Null until a hash is + /// available (e.g. multisig proposals, or before a Keystone signature). + final String? explorerUrl; + + const SendTerminalContent({ + required this.title, + required this.headline, + required this.subline, + required this.recipientAddress, + required this.recipientChecksum, + required this.doneLabel, + this.amountText, + this.signaturesLabel, + this.topSpacing = 0, + this.explorerUrl, + }); + + SendTerminalContent copyWith({String? explorerUrl}) => SendTerminalContent( + title: title, + headline: headline, + subline: subline, + recipientAddress: recipientAddress, + recipientChecksum: recipientChecksum, + doneLabel: doneLabel, + amountText: amountText, + signaturesLabel: signaturesLabel, + topSpacing: topSpacing, + explorerUrl: explorerUrl ?? this.explorerUrl, + ); +} + +/// Result of [SendStrategy.submit]. +sealed class SendOutcome { + const SendOutcome(); +} + +/// Submission accepted; show [terminal]. +class SendSubmitted extends SendOutcome { + final SendTerminalContent terminal; + + const SendSubmitted(this.terminal); +} + +/// The source account signs off-device (Keystone): hand off to the hardware QR +/// flow, which broadcasts and then shows [terminal]. +class SendNeedsHardwareSignature extends SendOutcome { + final Account account; + final BigInt networkFee; + final int blockHeight; + final SendTerminalContent terminal; + + const SendNeedsHardwareSignature({ + required this.account, + required this.networkFee, + required this.blockHeight, + required this.terminal, + }); +} + +/// Submission failed or was not authenticated; show [message] inline. +class SendFailed extends SendOutcome { + final String message; + + const SendFailed(this.message); +} + +/// Encapsulates everything that differs between the send and multisig-propose +/// flows so the recipient, amount, review and terminal screens can be shared. +abstract class SendStrategy { + const SendStrategy(); + + /// Account the funds leave from; the recipient must differ (self-guard) and + /// it is excluded from the recents list. Resolved via `ref.read`. + String? sourceAccountId(WidgetRef ref); + + SendStrings strings(AppLocalizations l10n); + + /// Balance the amount is drawn from. Exposed as a provider so the amount + /// screen can watch it in `build` and read it in event handlers. + ProviderListenable> get spendableBalanceProvider; + + /// Whether a secondary balance used for gating is still loading. Watched. + bool extraBalancesLoading(WidgetRef ref); + + /// Portion of [fee] charged against the spendable balance (drives the + /// max-sendable calculation and the insufficient-balance check). Zero for + /// flows where the fee is paid by a different account (e.g. multisig). + BigInt feeChargedToBalance(SendFee? fee); + + /// Estimates the fee for [amount] to [recipient]. Uses `ref.read`. Handles + /// the zero/invalid-amount estimate internally. + Future estimateFee(WidgetRef ref, {required String recipient, required BigInt amount}); + + /// Affordability gate beyond `amount <= spendable` (e.g. the proposing member + /// must cover the proposal cost). Returns an error label, or null when ok or + /// still loading. Watched in `build`. + String? affordabilityError(WidgetRef ref, SendFee fee, AppLocalizations l10n); + + /// Review-screen summary rows (already spaced). Built in `build`. + List reviewRows( + BuildContext context, + WidgetRef ref, { + required String recipientAddress, + required BigInt amount, + required SendFee fee, + }); + + /// Authenticates and submits. Uses `ref.read`. Never navigates. + Future submit( + WidgetRef ref, { + required String recipientAddress, + required String recipientChecksum, + required BigInt amount, + required SendFee fee, + required bool isPayMode, + }); +} diff --git a/mobile-app/lib/v2/screens/send/tx_submitted_screen.dart b/mobile-app/lib/v2/screens/send/send_terminal_screen.dart similarity index 58% rename from mobile-app/lib/v2/screens/send/tx_submitted_screen.dart rename to mobile-app/lib/v2/screens/send/send_terminal_screen.dart index 054539cb..f145fb6e 100644 --- a/mobile-app/lib/v2/screens/send/tx_submitted_screen.dart +++ b/mobile-app/lib/v2/screens/send/send_terminal_screen.dart @@ -1,84 +1,71 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/explorer_link.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; -class TxSubmittedScreen extends ConsumerWidget { - final BigInt amount; - final String recipientAddress; - final String? recipientChecksum; - final bool isPayMode; +/// Shared success screen for both regular sends and multisig proposals, +/// configured entirely by [SendTerminalContent]. +class SendTerminalScreen extends ConsumerWidget { + final SendTerminalContent content; - const TxSubmittedScreen({ - super.key, - required this.amount, - required this.recipientAddress, - this.recipientChecksum, - this.isPayMode = false, - }); + const SendTerminalScreen({super.key, required this.content}); void _popToHome(BuildContext context) { Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (_) => const HomeScreen()), (route) => false); } - String _headline(WidgetRef ref, AppLocalizations l10n) { - final formattingService = ref.watch(numberFormattingServiceProvider); - final n = formattingService.formatBalance(amount, smartDecimals: 4); - return isPayMode - ? l10n.sendTxSubmittedHeadlinePaid(n, AppConstants.tokenSymbol) - : l10n.sendTxSubmittedHeadlineSent(n, AppConstants.tokenSymbol); - } - @override Widget build(BuildContext context, WidgetRef ref) { final l10n = ref.watch(l10nProvider); final colors = context.colors; final text = context.themeText; - final addr = recipientAddress.trim(); - final shortAddr = AddressFormattingService.formatAddress(addr); + final shortAddr = AddressFormattingService.formatAddress(content.recipientAddress.trim()); + final checksum = content.recipientChecksum; + final signaturesLabel = content.signaturesLabel; return PopScope( canPop: false, - onPopInvokedWithResult: (bool didPop, Object? result) { + onPopInvokedWithResult: (didPop, _) { if (didPop) return; _popToHome(context); }, child: ScaffoldBase( - appBar: V2AppBar( - title: isPayMode ? l10n.sendPayTitle : l10n.sendTitle, - leading: AppBackButton(onTap: () => _popToHome(context)), - ), + appBar: V2AppBar(title: content.title, leading: AppBackButton(onTap: () => _popToHome(context))), mainContent: Column( children: [ Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 70), + if (content.topSpacing > 0) SizedBox(height: content.topSpacing), _successMark(colors), const SizedBox(height: 32), Text( - _headline(ref, l10n), + content.headline, textAlign: TextAlign.center, style: text.largeTitle?.copyWith(fontWeight: FontWeight.w400), ), const SizedBox(height: 4), Text( - l10n.sendTxSubmittedOnItsWay, + content.subline, textAlign: TextAlign.center, style: text.smallParagraph?.copyWith(color: colors.textTertiary, letterSpacing: 0.74), ), const SizedBox(height: 32), + if (content.amountText != null) ...[ + Text(content.amountText!, style: text.smallTitle?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 16), + ], Text.rich( textAlign: TextAlign.center, TextSpan( @@ -88,17 +75,14 @@ class TxSubmittedScreen extends ConsumerWidget { text: l10n.sendTxSubmittedToLabel, style: text.paragraph?.copyWith(fontWeight: FontWeight.w500), ), - TextSpan( - text: ':', - style: text.paragraph?.copyWith(fontWeight: FontWeight.w600), - ), + TextSpan(text: ':', style: text.paragraph?.copyWith(fontWeight: FontWeight.w600)), ], ), ), const SizedBox(height: 16), - if (recipientChecksum != null && recipientChecksum!.isNotEmpty) ...[ + if (checksum != null && checksum.isNotEmpty) ...[ Text( - recipientChecksum!, + checksum, textAlign: TextAlign.center, style: text.smallParagraph?.copyWith(color: colors.checksum, height: 1.0), ), @@ -114,14 +98,23 @@ class TxSubmittedScreen extends ConsumerWidget { height: 1.35, ), ), + if (signaturesLabel != null) ...[ + const SizedBox(height: 32), + _signaturesChip(colors, text, signaturesLabel), + ], ], ), ), + if (content.explorerUrl != null) ...[ + const Spacer(), + Center(child: ExplorerLink(url: content.explorerUrl)), + const SizedBox(height: 8), + ], ], ), bottomContent: ScaffoldBaseBottomContent( child: QuantusButton.simple( - label: l10n.sendTxSubmittedDone, + label: content.doneLabel, variant: ButtonVariant.primary, onTap: () => _popToHome(context), ), @@ -130,19 +123,32 @@ class TxSubmittedScreen extends ConsumerWidget { ); } - Widget _successMark(AppColorsV2 colors) { - final containerSize = 78.0; - final iconSize = 32.0; - + Widget _signaturesChip(AppColorsV2 colors, AppTextTheme text, String label) { return Container( - width: containerSize, - height: containerSize, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: colors.success, width: 2), + color: colors.surfaceDeep, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.borderButton.useOpacity(0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.fingerprint, size: 18, color: colors.checksum), + const SizedBox(width: 8), + Text(label, style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + ], ), + ); + } + + Widget _successMark(AppColorsV2 colors) { + return Container( + width: 78, + height: 78, + decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: colors.success, width: 2)), alignment: Alignment.center, - child: Icon(Icons.check, size: iconSize, color: colors.success), + child: Icon(Icons.check, size: 32, color: colors.success), ); } } From 296849e53e9735a3a24bcd4a0ae26f7a9d40717c Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 25 Jun 2026 19:51:49 +0800 Subject: [PATCH 3/9] debug scanner code in test mode --- .../lib/v2/components/qr_scanner_page.dart | 16 +++++++++++++++- .../accounts/add_hardware_account_screen.dart | 2 +- .../v2/screens/send/select_recipient_screen.dart | 3 +++ quantus_sdk/lib/src/constants/app_constants.dart | 5 +++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/mobile-app/lib/v2/components/qr_scanner_page.dart b/mobile-app/lib/v2/components/qr_scanner_page.dart index 2dd92f66..b6af3996 100644 --- a/mobile-app/lib/v2/components/qr_scanner_page.dart +++ b/mobile-app/lib/v2/components/qr_scanner_page.dart @@ -1,7 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/v2/components/quantus_icon_button.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; @@ -26,9 +28,13 @@ class _QrScannerPageState extends ConsumerState { } void _onDetect(BarcodeCapture capture) { - if (_scanned) return; final code = capture.barcodes.firstOrNull?.rawValue; if (code == null || code.isEmpty) return; + _handleCode(code); + } + + void _handleCode(String code) { + if (_scanned) return; if (widget.validator != null && !widget.validator!(code)) return; _scanned = true; Navigator.pop(context, code); @@ -84,6 +90,14 @@ class _QrScannerPageState extends ConsumerState { ), const SizedBox(width: 8), _actionButton(icon: Icons.image_outlined, onTap: _pickImage, colors: colors), + if (kDebugMode) ...[ + const SizedBox(width: 8), + _actionButton( + icon: Icons.bug_report, + onTap: () => _handleCode(AppConstants.debugTestAddress), + colors: colors, + ), + ], ], ), ), diff --git a/mobile-app/lib/v2/screens/accounts/add_hardware_account_screen.dart b/mobile-app/lib/v2/screens/accounts/add_hardware_account_screen.dart index 8cf677c4..4ace4dab 100644 --- a/mobile-app/lib/v2/screens/accounts/add_hardware_account_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/add_hardware_account_screen.dart @@ -46,7 +46,7 @@ class _AddHardwareAccountScreenState extends ConsumerState _error = null); } diff --git a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart index 5933c826..884e096f 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -138,7 +138,10 @@ class _SelectRecipientScreenState extends ConsumerState { ); if (scanResult == null || !mounted) return; final payment = PaymentIntent.tryParseUrl(scanResult); + print('payment: $payment'); + print('scanResult: $scanResult'); if (payment != null) { + print('to: ${payment?.to}, amount: ${payment?.amount}'); _setRecipient(payment.to, amount: payment.amount, isPayMode: true); } else { _setRecipient(scanResult); diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index 161fb46f..d761ad53 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -76,6 +76,11 @@ class AppConstants { // Always show the home backup nudge regardless of viewed state and balance static const bool debugAlwaysShowBackupNudge = false; + // Valid SS58 address returned/filled by debug buttons so address-entry flows + // (send, swap, add hardware account) can be exercised in the simulator where + // the camera is unavailable. + static const String debugTestAddress = 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'; + static const String accountSettingsRouteName = 'account-settings'; static const int highSecurityStepsCount = 3; } From 0c4ac98e8607f729c4af05813b75ec8edf9bdfdf Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 25 Jun 2026 19:52:00 +0800 Subject: [PATCH 4/9] formatting --- .../transaction_submission_service.dart | 5 +++- .../lib/v2/components/explorer_link.dart | 4 ++- .../lib/v2/screens/home/home_screen.dart | 4 ++- .../send/multisig_propose_strategy.dart | 25 +++++++++++++++---- .../screens/send/regular_send_strategy.dart | 9 ++++++- .../v2/screens/send/send_terminal_screen.dart | 15 ++++++++--- 6 files changed, 50 insertions(+), 12 deletions(-) diff --git a/mobile-app/lib/services/transaction_submission_service.dart b/mobile-app/lib/services/transaction_submission_service.dart index 4e755124..f8a9191f 100644 --- a/mobile-app/lib/services/transaction_submission_service.dart +++ b/mobile-app/lib/services/transaction_submission_service.dart @@ -52,7 +52,10 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_transfer'); // C. Submit and track the transaction - return submitAndTrackTransaction(() => BalancesService().balanceTransfer(account, targetAddress, amount), pendingTx); + return submitAndTrackTransaction( + () => BalancesService().balanceTransfer(account, targetAddress, amount), + pendingTx, + ); } /// Broadcasts a transfer whose signature was produced off-device (e.g. by a diff --git a/mobile-app/lib/v2/components/explorer_link.dart b/mobile-app/lib/v2/components/explorer_link.dart index ed5634c7..dee140aa 100644 --- a/mobile-app/lib/v2/components/explorer_link.dart +++ b/mobile-app/lib/v2/components/explorer_link.dart @@ -26,7 +26,9 @@ class ExplorerLink extends ConsumerWidget { onTap: active ? () => openUrl(url!) : null, child: Container( padding: const EdgeInsets.only(bottom: 3), - decoration: BoxDecoration(border: Border(bottom: BorderSide(color: linkColor, width: 1))), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: linkColor, width: 1)), + ), child: Text( l10n.activityDetailViewExplorer, style: context.themeText.smallParagraph?.copyWith(color: linkColor, fontWeight: FontWeight.w400), diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index b53ee82b..bc0b75bb 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -368,7 +368,9 @@ class _HomeScreenState extends ConsumerState { label: l10n.multisigProposeTitle, onTap: () => Navigator.push( context, - MaterialPageRoute(builder: (_) => SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig))), + MaterialPageRoute( + builder: (_) => SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig)), + ), ), ), ], diff --git a/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart b/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart index 2740acf1..3847b4ca 100644 --- a/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart +++ b/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart @@ -44,7 +44,8 @@ class MultisigProposeStrategy extends SendStrategy { ProviderListenable> get spendableBalanceProvider => balanceProviderFamily(msig.accountId); @override - bool extraBalancesLoading(WidgetRef ref) => ref.watch(effectiveBalanceProviderFamily(msig.myMemberAccountId)).isLoading; + bool extraBalancesLoading(WidgetRef ref) => + ref.watch(effectiveBalanceProviderFamily(msig.myMemberAccountId)).isLoading; // The proposal fee is paid by the member, not from the multisig balance. @override @@ -140,11 +141,23 @@ class MultisigProposeStrategy extends SendStrategy { valueStyle: valueStyle, ), const SizedBox(height: 4), - DetailSummaryRow.review(label: l10n.sendReviewNetworkFee, value: amt(breakdown.networkFee), valueStyle: valueStyle), + DetailSummaryRow.review( + label: l10n.sendReviewNetworkFee, + value: amt(breakdown.networkFee), + valueStyle: valueStyle, + ), const SizedBox(height: 4), - DetailSummaryRow.review(label: l10n.multisigProposalDepositLabel, value: amt(breakdown.deposit), valueStyle: valueStyle), + DetailSummaryRow.review( + label: l10n.multisigProposalDepositLabel, + value: amt(breakdown.deposit), + valueStyle: valueStyle, + ), const SizedBox(height: 4), - DetailSummaryRow.review(label: l10n.multisigProposeFeeRowLabel, value: amt(breakdown.creationFee), valueStyle: valueStyle), + DetailSummaryRow.review( + label: l10n.multisigProposeFeeRowLabel, + value: amt(breakdown.creationFee), + valueStyle: valueStyle, + ), const SizedBox(height: 4), DetailSummaryRow.review( label: l10n.multisigProposeMemberTotalLabel, @@ -202,7 +215,9 @@ class MultisigProposeStrategy extends SendStrategy { ref.invalidate(multisigPastProposalsProvider(msig)); ref.invalidate(multisigCurrentBlockProvider); - return SendSubmitted(_terminal(l10n, fmt, recipient: recipientAddress, checksum: recipientChecksum, amount: amount)); + return SendSubmitted( + _terminal(l10n, fmt, recipient: recipientAddress, checksum: recipientChecksum, amount: amount), + ); } catch (e, st) { debugPrint('Propose submit error: $e $st'); return SendFailed(l10n.multisigProposeSubmitFailed); diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index 5a835eac..a975941e 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -108,7 +108,14 @@ class RegularSendStrategy extends SendStrategy { final regularFee = fee as RegularFee; final recipient = recipientAddress.trim(); final account = (await SettingsService().getActiveRegularAccount())!; - final terminal = _terminal(l10n, fmt, recipient: recipient, checksum: recipientChecksum, amount: amount, isPayMode: isPayMode); + final terminal = _terminal( + l10n, + fmt, + recipient: recipient, + checksum: recipientChecksum, + amount: amount, + isPayMode: isPayMode, + ); // Keystone (hardware) accounts sign off-device: hand off to the QR flow // instead of signing locally. The debug flag forces this path for testing. diff --git a/mobile-app/lib/v2/screens/send/send_terminal_screen.dart b/mobile-app/lib/v2/screens/send/send_terminal_screen.dart index f145fb6e..21b68e5e 100644 --- a/mobile-app/lib/v2/screens/send/send_terminal_screen.dart +++ b/mobile-app/lib/v2/screens/send/send_terminal_screen.dart @@ -40,7 +40,10 @@ class SendTerminalScreen extends ConsumerWidget { _popToHome(context); }, child: ScaffoldBase( - appBar: V2AppBar(title: content.title, leading: AppBackButton(onTap: () => _popToHome(context))), + appBar: V2AppBar( + title: content.title, + leading: AppBackButton(onTap: () => _popToHome(context)), + ), mainContent: Column( children: [ Center( @@ -75,7 +78,10 @@ class SendTerminalScreen extends ConsumerWidget { text: l10n.sendTxSubmittedToLabel, style: text.paragraph?.copyWith(fontWeight: FontWeight.w500), ), - TextSpan(text: ':', style: text.paragraph?.copyWith(fontWeight: FontWeight.w600)), + TextSpan( + text: ':', + style: text.paragraph?.copyWith(fontWeight: FontWeight.w600), + ), ], ), ), @@ -146,7 +152,10 @@ class SendTerminalScreen extends ConsumerWidget { return Container( width: 78, height: 78, - decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: colors.success, width: 2)), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: colors.success, width: 2), + ), alignment: Alignment.center, child: Icon(Icons.check, size: 32, color: colors.success), ); From 34f18470d833a3184e870584e275ae2a36ae99ee Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 25 Jun 2026 22:52:40 +0800 Subject: [PATCH 5/9] handle self send error --- .../screens/send/select_recipient_screen.dart | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart index 884e096f..e19473ff 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -9,6 +9,7 @@ import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/routes.dart'; +import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.dart'; import 'package:resonance_network_wallet/v2/components/address_checkphrase_with_initial.dart'; import 'package:resonance_network_wallet/v2/components/address_input_field.dart'; import 'package:resonance_network_wallet/v2/components/loader.dart'; @@ -42,6 +43,7 @@ class _SelectRecipientScreenState extends ConsumerState { bool _loadingRecents = true; bool _isPayMode = false; bool _canContinue = false; + bool _isSelfSend = false; String? _recipientChecksum; @override @@ -93,6 +95,7 @@ class _SelectRecipientScreenState extends ConsumerState { _recipientChecksum = null; _isPayMode = false; _canContinue = false; + _isSelfSend = false; }); return; } @@ -104,11 +107,17 @@ class _SelectRecipientScreenState extends ConsumerState { final substrate = ref.read(substrateServiceProvider); final isValid = substrate.isValidSS58Address(address); final sourceId = widget.strategy.sourceAccountId(ref); + final isSelfSend = isValid && address == sourceId; + final showSelfSendWarning = isSelfSend && !_isSelfSend; setState(() { _hasAddressError = !isValid; + _isSelfSend = isSelfSend; _recipientChecksum = null; - _canContinue = isValid && address != sourceId; + _canContinue = isValid && !isSelfSend; }); + if (showSelfSendWarning) { + context.showWarningToaster(message: ref.read(l10nProvider).sendLogicCantSelfTransfer); + } if (isValid) { checksumService.getHumanReadableName(address).then((checksum) { if (mounted) setState(() => _recipientChecksum = checksum); @@ -138,10 +147,7 @@ class _SelectRecipientScreenState extends ConsumerState { ); if (scanResult == null || !mounted) return; final payment = PaymentIntent.tryParseUrl(scanResult); - print('payment: $payment'); - print('scanResult: $scanResult'); if (payment != null) { - print('to: ${payment?.to}, amount: ${payment?.amount}'); _setRecipient(payment.to, amount: payment.amount, isPayMode: true); } else { _setRecipient(scanResult); @@ -174,6 +180,7 @@ class _SelectRecipientScreenState extends ConsumerState { _recipientChecksum = null; _hasAddressError = true; _canContinue = false; + _isSelfSend = false; }); }); } @@ -339,7 +346,11 @@ class _SelectRecipientScreenState extends ConsumerState { } Widget _buildBottomButton(AppLocalizations l10n) { - final btnText = _canContinue ? l10n.sendSelectRecipientContinue : l10n.sendEnterAddress; + final btnText = _canContinue + ? l10n.sendSelectRecipientContinue + : _isSelfSend + ? l10n.sendLogicCantSelfTransfer + : l10n.sendEnterAddress; return ScaffoldBaseBottomContent( child: QuantusButton.simple( From 22a4d3690a4a273fe15a26d04ba1c461a2a10293 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 29 Jun 2026 18:14:59 +0800 Subject: [PATCH 6/9] Restore Scan QR Code scanner title Revert an accidental debug edit that changed componentQrScannerTitle to "X QR Code" across the arb and generated localization files. --- mobile-app/lib/l10n/app_en.arb | 2 +- mobile-app/lib/l10n/app_localizations.dart | 2 +- mobile-app/lib/l10n/app_localizations_en.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 09207eec..af37d696 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -2461,7 +2461,7 @@ "description": "Empty state on refund address picker" }, - "componentQrScannerTitle": "X QR Code", + "componentQrScannerTitle": "Scan QR Code", "@componentQrScannerTitle": { "description": "Text for app bar or button label on QR scanner component" }, diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 3682f951..6561199c 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -3179,7 +3179,7 @@ abstract class AppLocalizations { /// Text for app bar or button label on QR scanner component /// /// In en, this message translates to: - /// **'X QR Code'** + /// **'Scan QR Code'** String get componentQrScannerTitle; /// Snackbar when gallery image has no QR code diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 89489452..f4274950 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1689,7 +1689,7 @@ class AppLocalizationsEn extends AppLocalizations { String get swapRefundPickerEmpty => 'No recent refund addresses'; @override - String get componentQrScannerTitle => 'X QR Code'; + String get componentQrScannerTitle => 'Scan QR Code'; @override String get componentQrScannerNoCode => 'No QR code found in image'; From 05ab89c937786c0d653050b8a97c535bc8507b3d Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 30 Jun 2026 12:35:12 +0800 Subject: [PATCH 7/9] rename fetch fee counter and add comment --- .../lib/v2/screens/send/input_amount_screen.dart | 13 +++++++------ .../lib/v2/screens/send/send_terminal_screen.dart | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/mobile-app/lib/v2/screens/send/input_amount_screen.dart b/mobile-app/lib/v2/screens/send/input_amount_screen.dart index 6fa7f94e..ba8b45bc 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -57,7 +57,8 @@ class _InputAmountScreenState extends ConsumerState { bool _isFetchingFee = false; bool _hasFee = false; bool _feeFetchFailed = false; - int _feeFetchGeneration = 0; + // Each request has a counter value, so old responses can be ignored + int _fetchFeeCounter = 0; AmountInputLogic get _amountInputLogic => AmountInputLogic( exchangeRateService: ref.read(exchangeRateServiceProvider), @@ -137,19 +138,19 @@ class _InputAmountScreenState extends ConsumerState { } void _refreshFee() { - final generation = ++_feeFetchGeneration; + final counter = ++_fetchFeeCounter; final showLoader = !_hasFee || _feeFetchFailed; setState(() { _isFetchingFee = showLoader; if (showLoader) _feeFetchFailed = false; }); - _fetchFee(generation); + _fetchFee(counter); } - Future _fetchFee(int generation) async { + Future _fetchFee(int counter) async { try { final fee = await widget.strategy.estimateFee(ref, recipient: widget.recipientAddress.trim(), amount: _amount); - if (!mounted || generation != _feeFetchGeneration) return; + if (!mounted || counter != _fetchFeeCounter) return; setState(() { _fee = fee; _hasFee = true; @@ -158,7 +159,7 @@ class _InputAmountScreenState extends ConsumerState { }); } catch (e, st) { debugPrint('Fee fetch error: $e\n$st'); - if (!mounted || generation != _feeFetchGeneration) return; + if (!mounted || counter != _fetchFeeCounter) return; setState(() { _fee = null; _hasFee = false; diff --git a/mobile-app/lib/v2/screens/send/send_terminal_screen.dart b/mobile-app/lib/v2/screens/send/send_terminal_screen.dart index 21b68e5e..7889baff 100644 --- a/mobile-app/lib/v2/screens/send/send_terminal_screen.dart +++ b/mobile-app/lib/v2/screens/send/send_terminal_screen.dart @@ -149,9 +149,10 @@ class SendTerminalScreen extends ConsumerWidget { } Widget _successMark(AppColorsV2 colors) { + const size = 78.0; return Container( - width: 78, - height: 78, + width: size, + height: size, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: colors.success, width: 2), From 764b4f944b20763f741a97bd665fb0a3772ed3ad Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 30 Jun 2026 14:48:44 +0800 Subject: [PATCH 8/9] debug address change --- quantus_sdk/lib/src/constants/app_constants.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index d761ad53..5172c51d 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -79,7 +79,7 @@ class AppConstants { // Valid SS58 address returned/filled by debug buttons so address-entry flows // (send, swap, add hardware account) can be exercised in the simulator where // the camera is unavailable. - static const String debugTestAddress = 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'; + static const String debugTestAddress = 'qznQKhufTDfU3szAzfgCny7wMhxUN3qjEqneiRUNgC7MjSDyG'; static const String accountSettingsRouteName = 'account-settings'; static const int highSecurityStepsCount = 3; From 4e6de5615d5fdaa601bbb8ee014c1fb4e1c681f8 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 30 Jun 2026 14:49:06 +0800 Subject: [PATCH 9/9] format --- mobile-app/lib/v2/screens/send/input_amount_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile-app/lib/v2/screens/send/input_amount_screen.dart b/mobile-app/lib/v2/screens/send/input_amount_screen.dart index ba8b45bc..1a26da66 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -58,7 +58,7 @@ class _InputAmountScreenState extends ConsumerState { bool _hasFee = false; bool _feeFetchFailed = false; // Each request has a counter value, so old responses can be ignored - int _fetchFeeCounter = 0; + int _fetchFeeCounter = 0; AmountInputLogic get _amountInputLogic => AmountInputLogic( exchangeRateService: ref.read(exchangeRateServiceProvider),