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..f8a9191f 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,16 @@ 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 +83,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_transfer_hardware'); - await submitAndTrackTransaction( + return submitAndTrackTransaction( () => SubstrateService().submitExtrinsicWithExternalSignature(unsignedData, signature, publicKey), pendingTx, ); @@ -400,7 +403,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 +419,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..dee140aa --- /dev/null +++ b/mobile-app/lib/v2/components/explorer_link.dart @@ -0,0 +1,39 @@ +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/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/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..bc0b75bb 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,12 @@ 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 7d0afaab..00000000 --- a/mobile-app/lib/v2/screens/multisig/propose/propose_recipient_screen.dart +++ /dev/null @@ -1,418 +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; - } - - 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) { - setState(() { - _recipientController.text = payment.to; - _amountController.text = payment.amount; - _isPayMode = true; - }); - } else { - setState(() { - _recipientController.text = scanResult; - _isPayMode = false; - }); - } - } - - 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) { - _amountController.clear(); - setState(() => _isPayMode = false); - _recipientController.text = 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; - } - - @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..3847b4ca --- /dev/null +++ b/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart @@ -0,0 +1,245 @@ +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..a975941e --- /dev/null +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -0,0 +1,171 @@ +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 a3f4643a..e19473ff 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -4,12 +4,12 @@ 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'; 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'; @@ -19,11 +19,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 +42,8 @@ class _SelectRecipientScreenState extends ConsumerState { bool _hasAddressError = true; bool _loadingRecents = true; bool _isPayMode = false; + bool _canContinue = false; + bool _isSelfSend = false; String? _recipientChecksum; @override @@ -59,13 +64,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 +94,8 @@ class _SelectRecipientScreenState extends ConsumerState { _hasAddressError = true; _recipientChecksum = null; _isPayMode = false; + _canContinue = false; + _isSelfSend = false; }); return; } @@ -101,10 +106,18 @@ 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); + final isSelfSend = isValid && address == sourceId; + final showSelfSendWarning = isSelfSend && !_isSelfSend; setState(() { _hasAddressError = !isValid; + _isSelfSend = isSelfSend; _recipientChecksum = null; + _canContinue = isValid && !isSelfSend; }); + if (showSelfSendWarning) { + context.showWarningToaster(message: ref.read(l10nProvider).sendLogicCantSelfTransfer); + } if (isValid) { checksumService.getHumanReadableName(address).then((checksum) { if (mounted) setState(() => _recipientChecksum = checksum); @@ -112,13 +125,13 @@ 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. + void _setRecipient(String address, {String amount = '', bool isPayMode = false}) { + _amountController.text = amount; + setState(() => _isPayMode = isPayMode); + _recipientController.text = address; } Future _scanQr() async { @@ -135,16 +148,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); } } @@ -157,6 +163,7 @@ class _SelectRecipientScreenState extends ConsumerState { MaterialPageRoute( settings: inputAmountScreenRouteSettings, builder: (_) => InputAmountScreen( + strategy: widget.strategy, recipientAddress: address, recipientChecksum: _recipientChecksum, initialAmount: _amountController.text, @@ -172,41 +179,37 @@ class _SelectRecipientScreenState extends ConsumerState { setState(() { _recipientChecksum = null; _hasAddressError = true; + _canContinue = false; + _isSelfSend = false; }); }); } - 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 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), @@ -343,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( 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 63% 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..21b68e5e 100644 --- a/mobile-app/lib/v2/screens/send/tx_submitted_screen.dart +++ b/mobile-app/lib/v2/screens/send/send_terminal_screen.dart @@ -1,61 +1,47 @@ 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, + title: content.title, leading: AppBackButton(onTap: () => _popToHome(context)), ), mainContent: Column( @@ -64,21 +50,25 @@ class TxSubmittedScreen extends ConsumerWidget { 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( @@ -96,9 +86,9 @@ class TxSubmittedScreen extends ConsumerWidget { ), ), 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 +104,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 +129,35 @@ 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( + 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(label, style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + ], + ), + ); + } + Widget _successMark(AppColorsV2 colors) { return Container( - width: containerSize, - height: containerSize, + 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), ); } } 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; }