From a091fdd51621d0e56aba8216b5fd38f6dad4249f Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 22 May 2026 12:31:27 +0800 Subject: [PATCH 1/4] fix: parsing pay intent url --- .../lib/providers/route_intent_providers.dart | 2 + mobile-app/lib/services/pos_service.dart | 14 +++++-- .../lib/shared/utils/amount_input_logic.dart | 18 +++++++++ .../lib/v2/screens/pos/pos_qr_screen.dart | 6 ++- .../v2/screens/send/input_amount_screen.dart | 13 ++----- .../screens/send/select_recipient_screen.dart | 12 +++++- .../test/unit/amount_input_logic_test.dart | 34 ++++++++++++++++ .../unit/number_formatting_service_test.dart | 36 +++++++++++++++++ mobile-app/test/unit/pos_service_test.dart | 21 ++++++++++ .../services/number_formatting_service.dart | 39 +++++++++++++++++++ 10 files changed, 180 insertions(+), 15 deletions(-) create mode 100644 mobile-app/test/unit/pos_service_test.dart diff --git a/mobile-app/lib/providers/route_intent_providers.dart b/mobile-app/lib/providers/route_intent_providers.dart index 420f91fd..c216c985 100644 --- a/mobile-app/lib/providers/route_intent_providers.dart +++ b/mobile-app/lib/providers/route_intent_providers.dart @@ -11,6 +11,8 @@ class PaymentIntent { const PaymentIntent({required this.to, required this.amount, this.ref}); + BigInt? amountPlanck(NumberFormattingService formatting) => formatting.parseWireAmount(amount); + static PaymentIntent? tryParseUrl(String input) { final uri = Uri.tryParse(input); if (uri == null || uri.pathSegments.isEmpty || uri.pathSegments.first != 'pay') return null; diff --git a/mobile-app/lib/services/pos_service.dart b/mobile-app/lib/services/pos_service.dart index b005c5e2..7af8a22b 100644 --- a/mobile-app/lib/services/pos_service.dart +++ b/mobile-app/lib/services/pos_service.dart @@ -1,3 +1,5 @@ +import 'package:quantus_sdk/quantus_sdk.dart'; + class PosPaymentRequest { final String paymentUrl; final String refId; @@ -7,6 +9,11 @@ class PosPaymentRequest { } class PosService { + final NumberFormattingService _formattingService; + + PosService({NumberFormattingService? formattingService}) + : _formattingService = formattingService ?? NumberFormattingService(); + String generateRefId() { final now = DateTime.now().millisecondsSinceEpoch; return now.toRadixString(36).toUpperCase(); @@ -17,9 +24,10 @@ class PosService { return uri.toString(); } - PosPaymentRequest createPaymentRequest({required String accountId, required String amount}) { + PosPaymentRequest createPaymentRequest({required String accountId, required BigInt amountPlanck}) { final refId = generateRefId(); - final url = buildPaymentUrl(accountId: accountId, amount: amount, refId: refId); - return PosPaymentRequest(paymentUrl: url, refId: refId, amount: amount); + final wireAmount = _formattingService.formatWireAmount(amountPlanck); + final url = buildPaymentUrl(accountId: accountId, amount: wireAmount, refId: refId); + return PosPaymentRequest(paymentUrl: url, refId: refId, amount: wireAmount); } } diff --git a/mobile-app/lib/shared/utils/amount_input_logic.dart b/mobile-app/lib/shared/utils/amount_input_logic.dart index db0d6d4c..ce9fffc1 100644 --- a/mobile-app/lib/shared/utils/amount_input_logic.dart +++ b/mobile-app/lib/shared/utils/amount_input_logic.dart @@ -17,6 +17,13 @@ class ToggledInputResult { int get hashCode => text.hashCode ^ amount.hashCode; } +class PaymentUrlAmountResult { + final BigInt planck; + final String displayText; + + const PaymentUrlAmountResult({required this.planck, required this.displayText}); +} + class AmountInputLogic { final ExchangeRateService exchangeRateService; final FiatCurrency selectedFiat; @@ -49,6 +56,17 @@ class AmountInputLogic { return exchangeRateService.fiatToQuanRaw(fiatDecimal, selectedFiat, AppConstants.decimals); } + /// Parses a payment URL amount and formats it for the user's locale. + PaymentUrlAmountResult parsePaymentUrlAmount(String wireAmount, {required bool isFlipped}) { + final planck = formattingService.parseWireAmount(wireAmount) ?? BigInt.zero; + final displayText = planck == BigInt.zero + ? '' + : isFlipped + ? quanToFiatString(planck) + : formatQuanAmount(planck); + return PaymentUrlAmountResult(planck: planck, displayText: displayText); + } + /// Parses a QUAN amount string. BigInt parseQuanAmount(String text) { if (text.isEmpty) return BigInt.zero; 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 ef3aa7d8..b4931338 100644 --- a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart @@ -31,7 +31,6 @@ class PosQrScreen extends ConsumerStatefulWidget { } class _PosQrScreenState extends ConsumerState { - final _posService = PosService(); PosPaymentRequest? _request; final _txWatch = TxWatchService(); @@ -175,7 +174,10 @@ class _PosQrScreenState extends ConsumerState { ), data: (active) { if (active == null) return const Center(child: Text('No active account')); - _request ??= _posService.createPaymentRequest(accountId: active.account.accountId, amount: widget.amount); + _request ??= PosService(formattingService: formattingService).createPaymentRequest( + accountId: active.account.accountId, + amountPlanck: planck, + ); if (_isPaid) return _buildPaidContent(colors, text, display.primaryAmount); return _buildQrContent(_request!, colors, text, display); }, 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 05ca1c8c..4f2c4e19 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -67,15 +67,10 @@ class _InputAmountScreenState extends ConsumerState { _amountFocus.addListener(_onAmountFocusChanged); if (widget.initialAmount != null) { final isFlipped = ref.read(isCurrencyFlippedProvider); - if (!isFlipped) { - _amount = _amountInputLogic.parseQuanAmount(widget.initialAmount!); - _amountController.text = widget.initialAmount!; - } else { - final parsed = _amountInputLogic.parseQuanAmount(widget.initialAmount!); - if (parsed > BigInt.zero) { - _amount = parsed; - _amountController.text = _amountInputLogic.quanToFiatString(parsed); - } + final parsed = _amountInputLogic.parsePaymentUrlAmount(widget.initialAmount!, isFlipped: isFlipped); + if (parsed.planck > BigInt.zero) { + _amount = parsed.planck; + _amountController.text = parsed.displayText; } } if (widget.recipientChecksum != null) { diff --git a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart index 47765482..a35993f3 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -4,8 +4,10 @@ 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/providers/currency_display_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/shared/utils/amount_input_logic.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'; @@ -130,9 +132,17 @@ class _SelectRecipientScreenState extends ConsumerState { if (scanResult == null || !mounted) return; final payment = PaymentIntent.tryParseUrl(scanResult); if (payment != null) { + final amountInputLogic = AmountInputLogic( + exchangeRateService: ref.read(exchangeRateServiceProvider), + selectedFiat: ref.read(selectedFiatCurrencyProvider), + localeConfig: ref.read(localeNumberConfigProvider), + formattingService: ref.read(numberFormattingServiceProvider), + ); + final isFlipped = ref.read(isCurrencyFlippedProvider); + final parsed = amountInputLogic.parsePaymentUrlAmount(payment.amount, isFlipped: isFlipped); setState(() { _recipientController.text = payment.to; - _amountController.text = payment.amount; + _amountController.text = parsed.displayText; _isPayMode = true; }); } else { diff --git a/mobile-app/test/unit/amount_input_logic_test.dart b/mobile-app/test/unit/amount_input_logic_test.dart index 85a04294..448b0552 100644 --- a/mobile-app/test/unit/amount_input_logic_test.dart +++ b/mobile-app/test/unit/amount_input_logic_test.dart @@ -77,5 +77,39 @@ void main() { final logic = createLogic(); expect(logic.formatQuanAmount(BigInt.zero), ''); }); + + test('parsePaymentUrlAmount localizes canonical wire amount for payer locale', () { + final logic = createLogic(); + final result = logic.parsePaymentUrlAmount('1.5', isFlipped: false); + + expect(result.planck, BigInt.from(1500000000000)); + expect(result.displayText, '1.5'); + }); + + test('parsePaymentUrlAmount localizes legacy comma-decimal wire amount', () { + final logic = createLogic(); + final result = logic.parsePaymentUrlAmount('1,5', isFlipped: false); + + expect(result.planck, BigInt.from(1500000000000)); + expect(result.displayText, '1.5'); + }); + + test('parsePaymentUrlAmount displays comma decimal for comma-locale payers', () { + localeConfig = LocaleNumberConfig.commaDecimal; + formattingService = NumberFormattingService(localeConfig: localeConfig); + final logic = createLogic(); + final result = logic.parsePaymentUrlAmount('1.5', isFlipped: false); + + expect(result.planck, BigInt.from(1500000000000)); + expect(result.displayText, '1,5'); + }); + + test('parsePaymentUrlAmount returns empty display text for zero amount', () { + final logic = createLogic(); + final result = logic.parsePaymentUrlAmount('', isFlipped: false); + + expect(result.planck, BigInt.zero); + expect(result.displayText, ''); + }); }); } diff --git a/mobile-app/test/unit/number_formatting_service_test.dart b/mobile-app/test/unit/number_formatting_service_test.dart index 5de0f8b4..0bb43a24 100644 --- a/mobile-app/test/unit/number_formatting_service_test.dart +++ b/mobile-app/test/unit/number_formatting_service_test.dart @@ -89,5 +89,41 @@ void main() { expect(service.parseAmount('--5'), isNull); }); }); + + group('formatWireAmount / parseWireAmount', () { + test('formatWireAmount always uses dot decimal without grouping', () { + final commaService = NumberFormattingService(localeConfig: LocaleNumberConfig.commaDecimal); + final balance = BigInt.parse('1500000000000'); + expect(commaService.formatWireAmount(balance), '1.5'); + }); + + test('round-trips canonical wire amounts', () { + final balance = BigInt.parse('1500000000000'); + final wire = service.formatWireAmount(balance); + expect(wire, '1.5'); + expect(service.parseWireAmount(wire), balance); + }); + + test('parses legacy comma-decimal amounts', () { + expect(service.parseWireAmount('1,5'), BigInt.parse('1500000000000')); + }); + + test('parses legacy dot-decimal amounts', () { + expect(service.parseWireAmount('1.5'), BigInt.parse('1500000000000')); + }); + + test('parses integer amounts', () { + expect(service.parseWireAmount('1000'), scaleFactor * BigInt.from(1000)); + }); + + test('parses mixed separators using rightmost decimal mark', () { + expect(service.parseWireAmount('1.000,50'), BigInt.parse('1000500000000000')); + expect(service.parseWireAmount('1,000.50'), BigInt.parse('1000500000000000')); + }); + + test('returns zero for empty wire amount', () { + expect(service.parseWireAmount(''), BigInt.zero); + }); + }); }); } diff --git a/mobile-app/test/unit/pos_service_test.dart b/mobile-app/test/unit/pos_service_test.dart new file mode 100644 index 00000000..774592b9 --- /dev/null +++ b/mobile-app/test/unit/pos_service_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/services/pos_service.dart'; + +void main() { + group('PosService', () { + test('createPaymentRequest embeds canonical dot-decimal wire amount', () { + final formattingService = NumberFormattingService(localeConfig: LocaleNumberConfig.commaDecimal); + final service = PosService(formattingService: formattingService); + final amountPlanck = BigInt.parse('1500000000000'); + + final request = service.createPaymentRequest(accountId: 'account123', amountPlanck: amountPlanck); + + expect(request.amount, '1.5'); + expect(request.paymentUrl, contains('amount=1.5')); + expect(request.paymentUrl, isNot(contains('amount=1%2C5'))); + expect(request.paymentUrl, contains('to=account123')); + expect(request.refId, isNotEmpty); + }); + }); +} diff --git a/quantus_sdk/lib/src/services/number_formatting_service.dart b/quantus_sdk/lib/src/services/number_formatting_service.dart index d30b6671..64e85dd4 100644 --- a/quantus_sdk/lib/src/services/number_formatting_service.dart +++ b/quantus_sdk/lib/src/services/number_formatting_service.dart @@ -58,6 +58,45 @@ class NumberFormattingService { return resultString; } + /// Formats a balance for payment URL wire transport: dot decimal, no grouping. + /// + /// Wire amounts are locale-neutral and must be parsed with [parseWireAmount]. + String formatWireAmount(BigInt balance) { + return NumberFormattingService(localeConfig: LocaleNumberConfig.dotDecimal).formatBalance( + balance, + maxDecimals: decimals, + addThousandsSeparators: false, + ); + } + + /// Parses a payment URL amount without assuming the payer's locale. + /// + /// Supports canonical dot-decimal wire amounts and legacy locale-formatted + /// amounts from older POS QR codes. + BigInt? parseWireAmount(String formattedAmount) { + if (formattedAmount.isEmpty) { + return BigInt.zero; + } + + final config = _wireLocaleConfigFor(formattedAmount); + return NumberFormattingService(localeConfig: config).parseAmount(formattedAmount); + } + + static LocaleNumberConfig _wireLocaleConfigFor(String input) { + final hasComma = input.contains(','); + final hasDot = input.contains('.'); + + if (hasComma && hasDot) { + final lastComma = input.lastIndexOf(','); + final lastDot = input.lastIndexOf('.'); + return lastComma > lastDot ? LocaleNumberConfig.commaDecimal : LocaleNumberConfig.dotDecimal; + } + if (hasComma) { + return LocaleNumberConfig.commaDecimal; + } + return LocaleNumberConfig.dotDecimal; + } + /// Parses a user-entered formatted string amount into a raw BigInt amount /// scaled by the chain's decimals. /// From 19792c298809040741aa092bf88f60e274c46025 Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 22 May 2026 13:45:16 +0800 Subject: [PATCH 2/4] fix: intent processing bug --- .../shared_address_action_sheet.dart | 4 ++++ mobile-app/lib/routes.dart | 5 +++++ .../extensions/current_route_extensions.dart | 14 ++++++++++++++ .../v2/components/bottom_sheet_container.dart | 3 ++- .../activity/transaction_detail_sheet.dart | 5 +++++ .../lib/v2/screens/home/home_screen.dart | 19 ++++++++++++++----- .../lib/v2/screens/pos/pos_qr_screen.dart | 7 +++---- .../screens/send/select_recipient_screen.dart | 2 ++ .../services/number_formatting_service.dart | 8 +++----- 9 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 mobile-app/lib/routes.dart create mode 100644 mobile-app/lib/shared/extensions/current_route_extensions.dart 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 26f84f09..5e8a3ea1 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/routes.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; +import 'package:resonance_network_wallet/shared/extensions/current_route_extensions.dart'; 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'; @@ -215,6 +217,8 @@ class _SharedAddressActionSheetState extends State { // Helper function to show the receive sheet void showSharedAddressActionSheet(BuildContext context, String address) { + if (context.peekTopRouteName == sharedAccountSheetRouteSettings.name) Navigator.pop(context); + showModalBottomSheet( context: context, backgroundColor: Colors.transparent, diff --git a/mobile-app/lib/routes.dart b/mobile-app/lib/routes.dart new file mode 100644 index 00000000..c686f95a --- /dev/null +++ b/mobile-app/lib/routes.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +const RouteSettings transactionDetailSheetRouteSettings = RouteSettings(name: 'transaction_detail_sheet'); +const RouteSettings inputAmountScreenRouteSettings = RouteSettings(name: 'input_amount_screen'); +const RouteSettings sharedAccountSheetRouteSettings = RouteSettings(name: 'shared_account_sheet'); diff --git a/mobile-app/lib/shared/extensions/current_route_extensions.dart b/mobile-app/lib/shared/extensions/current_route_extensions.dart new file mode 100644 index 00000000..81f97150 --- /dev/null +++ b/mobile-app/lib/shared/extensions/current_route_extensions.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +extension CurrentRouteExtensions on BuildContext { + String? get peekTopRouteName { + String? topRouteName; + + Navigator.popUntil(this, (route) { + topRouteName = route.settings.name; + return true; + }); + + return topRouteName; + } +} diff --git a/mobile-app/lib/v2/components/bottom_sheet_container.dart b/mobile-app/lib/v2/components/bottom_sheet_container.dart index 46e4697e..147c3d40 100644 --- a/mobile-app/lib/v2/components/bottom_sheet_container.dart +++ b/mobile-app/lib/v2/components/bottom_sheet_container.dart @@ -83,9 +83,10 @@ class BottomSheetContainer extends StatelessWidget { ); } - static Future show(BuildContext context, {required WidgetBuilder builder}) { + static Future show(BuildContext context, {required WidgetBuilder builder, RouteSettings? routeSettings}) { return showModalBottomSheet( context: context, + routeSettings: routeSettings, backgroundColor: Colors.transparent, isScrollControlled: true, constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width), 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 ef5ef693..8f604959 100644 --- a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart +++ b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart @@ -4,6 +4,8 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/dotted_border.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/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'; @@ -12,9 +14,12 @@ import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; void showTransactionDetailSheet(BuildContext context, TransactionEvent tx, String activeAccountId) { + if (context.peekTopRouteName == transactionDetailSheetRouteSettings.name) Navigator.pop(context); + BottomSheetContainer.show( context, builder: (_) => _TransactionDetailSheet(tx: tx, activeAccountId: activeAccountId), + routeSettings: transactionDetailSheetRouteSettings, ); } diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 28a649fb..f9cecef1 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -6,6 +6,8 @@ import 'package:resonance_network_wallet/features/components/dotted_border.dart' import 'package:resonance_network_wallet/features/components/skeleton.dart'; import 'package:resonance_network_wallet/features/components/shared_address_action_sheet.dart'; import 'package:resonance_network_wallet/providers/remote_config_provider.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/url_utils.dart'; import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; import 'package:resonance_network_wallet/v2/components/loader.dart'; @@ -68,23 +70,30 @@ class _HomeScreenState extends ConsumerState { final active = ref.read(activeAccountProvider).value; if (active == null) return; ref.read(transactionIntentProvider.notifier).state = null; + showTransactionDetailSheet(context, transaction, active.account.accountId); } void _onPaymentIntent(PaymentIntent? _, PaymentIntent? payment) { if (payment == null || !mounted) return; ref.read(paymentIntentProvider.notifier).state = null; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => InputAmountScreen(recipientAddress: payment.to, initialAmount: payment.amount, isPayMode: true), - ), + + final pageRoute = MaterialPageRoute( + builder: (_) => InputAmountScreen(recipientAddress: payment.to, initialAmount: payment.amount, isPayMode: true), + settings: inputAmountScreenRouteSettings, ); + + if (context.peekTopRouteName == inputAmountScreenRouteSettings.name) { + Navigator.pushReplacement(context, pageRoute); + } else { + Navigator.push(context, pageRoute); + } } void _onSharedIntent(String? _, String? shared) { if (shared == null || !mounted) return; ref.read(sharedAccountIntentProvider.notifier).state = null; + showSharedAddressActionSheet(context, shared); } 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 b4931338..dda3ccda 100644 --- a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart @@ -174,10 +174,9 @@ class _PosQrScreenState extends ConsumerState { ), data: (active) { if (active == null) return const Center(child: Text('No active account')); - _request ??= PosService(formattingService: formattingService).createPaymentRequest( - accountId: active.account.accountId, - amountPlanck: planck, - ); + _request ??= PosService( + formattingService: formattingService, + ).createPaymentRequest(accountId: active.account.accountId, amountPlanck: planck); if (_isPaid) return _buildPaidContent(colors, text, display.primaryAmount); return _buildQrContent(_request!, colors, text, display); }, 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 a35993f3..024795f2 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -7,6 +7,7 @@ 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/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/utils/amount_input_logic.dart'; import 'package:resonance_network_wallet/v2/components/address_checkphrase_with_initial.dart'; import 'package:resonance_network_wallet/v2/components/loader.dart'; @@ -160,6 +161,7 @@ class _SelectRecipientScreenState extends ConsumerState { Navigator.push( context, MaterialPageRoute( + settings: inputAmountScreenRouteSettings, builder: (_) => InputAmountScreen( recipientAddress: address, recipientChecksum: _recipientChecksum, diff --git a/quantus_sdk/lib/src/services/number_formatting_service.dart b/quantus_sdk/lib/src/services/number_formatting_service.dart index 64e85dd4..648d97d7 100644 --- a/quantus_sdk/lib/src/services/number_formatting_service.dart +++ b/quantus_sdk/lib/src/services/number_formatting_service.dart @@ -62,11 +62,9 @@ class NumberFormattingService { /// /// Wire amounts are locale-neutral and must be parsed with [parseWireAmount]. String formatWireAmount(BigInt balance) { - return NumberFormattingService(localeConfig: LocaleNumberConfig.dotDecimal).formatBalance( - balance, - maxDecimals: decimals, - addThousandsSeparators: false, - ); + return NumberFormattingService( + localeConfig: LocaleNumberConfig.dotDecimal, + ).formatBalance(balance, maxDecimals: decimals, addThousandsSeparators: false); } /// Parses a payment URL amount without assuming the payer's locale. From 9377a41229c1a4ab5aa7d0fc65fe89dc54bfbdff Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 22 May 2026 15:48:48 +0800 Subject: [PATCH 3/4] fix: not adding route setting to shared address action sheet method call --- .../lib/features/components/shared_address_action_sheet.dart | 1 + 1 file changed, 1 insertion(+) 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 5e8a3ea1..796d59c5 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -221,6 +221,7 @@ void showSharedAddressActionSheet(BuildContext context, String address) { showModalBottomSheet( context: context, + routeSettings: sharedAccountSheetRouteSettings, backgroundColor: Colors.transparent, isScrollControlled: true, constraints: BoxConstraints( From be858c9f151571b6eb06b5df6eeffc196eb15b18 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 22 May 2026 16:37:25 +0800 Subject: [PATCH 4/4] simplified version with transport always in quan (#495) * simplified version with transport always in quan * format --- .../lib/providers/route_intent_providers.dart | 2 - .../lib/shared/utils/amount_input_logic.dart | 18 ------ .../lib/v2/screens/pos/pos_amount_screen.dart | 3 +- .../lib/v2/screens/pos/pos_qr_screen.dart | 28 +++++---- .../v2/screens/send/input_amount_screen.dart | 59 ++++++++++--------- .../screens/send/select_recipient_screen.dart | 12 +--- .../test/unit/amount_input_logic_test.dart | 34 ----------- 7 files changed, 49 insertions(+), 107 deletions(-) diff --git a/mobile-app/lib/providers/route_intent_providers.dart b/mobile-app/lib/providers/route_intent_providers.dart index c216c985..420f91fd 100644 --- a/mobile-app/lib/providers/route_intent_providers.dart +++ b/mobile-app/lib/providers/route_intent_providers.dart @@ -11,8 +11,6 @@ class PaymentIntent { const PaymentIntent({required this.to, required this.amount, this.ref}); - BigInt? amountPlanck(NumberFormattingService formatting) => formatting.parseWireAmount(amount); - static PaymentIntent? tryParseUrl(String input) { final uri = Uri.tryParse(input); if (uri == null || uri.pathSegments.isEmpty || uri.pathSegments.first != 'pay') return null; diff --git a/mobile-app/lib/shared/utils/amount_input_logic.dart b/mobile-app/lib/shared/utils/amount_input_logic.dart index ce9fffc1..db0d6d4c 100644 --- a/mobile-app/lib/shared/utils/amount_input_logic.dart +++ b/mobile-app/lib/shared/utils/amount_input_logic.dart @@ -17,13 +17,6 @@ class ToggledInputResult { int get hashCode => text.hashCode ^ amount.hashCode; } -class PaymentUrlAmountResult { - final BigInt planck; - final String displayText; - - const PaymentUrlAmountResult({required this.planck, required this.displayText}); -} - class AmountInputLogic { final ExchangeRateService exchangeRateService; final FiatCurrency selectedFiat; @@ -56,17 +49,6 @@ class AmountInputLogic { return exchangeRateService.fiatToQuanRaw(fiatDecimal, selectedFiat, AppConstants.decimals); } - /// Parses a payment URL amount and formats it for the user's locale. - PaymentUrlAmountResult parsePaymentUrlAmount(String wireAmount, {required bool isFlipped}) { - final planck = formattingService.parseWireAmount(wireAmount) ?? BigInt.zero; - final displayText = planck == BigInt.zero - ? '' - : isFlipped - ? quanToFiatString(planck) - : formatQuanAmount(planck); - return PaymentUrlAmountResult(planck: planck, displayText: displayText); - } - /// Parses a QUAN amount string. BigInt parseQuanAmount(String text) { if (text.isEmpty) return BigInt.zero; diff --git a/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart b/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart index 6e519630..09bbe2d5 100644 --- a/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart @@ -54,8 +54,7 @@ class _PosAmountScreenState extends ConsumerState { void _onCharge() { if (_amount <= BigInt.zero) return; - final quanString = _amountInputLogic.formatQuanAmount(_amount); - Navigator.push(context, MaterialPageRoute(builder: (_) => PosQrScreen(amount: quanString))); + Navigator.push(context, MaterialPageRoute(builder: (_) => PosQrScreen(amountPlanck: _amount))); } Future _toggleFlip() async { 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 dda3ccda..3f5023df 100644 --- a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart @@ -23,8 +23,8 @@ import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; class PosQrScreen extends ConsumerStatefulWidget { - final String amount; - const PosQrScreen({super.key, required this.amount}); + final BigInt amountPlanck; + const PosQrScreen({super.key, required this.amountPlanck}); @override ConsumerState createState() => _PosQrScreenState(); @@ -50,13 +50,11 @@ class _PosQrScreenState extends ConsumerState { } void _startWatching() { - final formattingService = ref.watch(numberFormattingServiceProvider); final active = ref.read(activeAccountProvider).value; if (active == null) return; - final expectedPlanck = formattingService.parseAmount(widget.amount); - if (expectedPlanck == null) { - print('[PosQr] ERROR: failed to parse amount "${widget.amount}"'); + if (widget.amountPlanck <= BigInt.zero) { + print('[PosQr] ERROR: invalid amount planck ${widget.amountPlanck}'); if (mounted) setState(() => _watchError = 'Invalid amount. Tap to retry.'); return; } @@ -66,15 +64,15 @@ class _PosQrScreenState extends ConsumerState { _watchError = null; }); - print('[PosQr] watching address=${active.account.accountId} expected=$expectedPlanck planck'); + print('[PosQr] watching address=${active.account.accountId} expected=${widget.amountPlanck} planck'); _txWatch.watch( address: active.account.accountId, onTransfer: (tx) { print('[PosQr] onTransfer from=${tx.from} amount=${tx.amount} hash=${tx.txHash}'); if (_isPaid) return; final received = BigInt.tryParse(tx.amount); - if (received != expectedPlanck) { - print('[PosQr] amount mismatch (received=$received expected=$expectedPlanck), ignoring'); + if (received != widget.amountPlanck) { + print('[PosQr] amount mismatch (received=$received expected=${widget.amountPlanck}), ignoring'); return; } @@ -83,7 +81,7 @@ class _PosQrScreenState extends ConsumerState { tempId: 'pending_recv_${DateTime.now().millisecondsSinceEpoch}', from: tx.from, to: active.account.accountId, - amount: expectedPlanck, + amount: widget.amountPlanck, timestamp: DateTime.now(), transactionState: TransactionState.pending, isReversible: false, @@ -162,8 +160,12 @@ class _PosQrScreenState extends ConsumerState { final text = context.themeText; final accountAsync = ref.watch(activeAccountProvider); final formattingService = ref.watch(numberFormattingServiceProvider); - final planck = formattingService.parseAmount(widget.amount) ?? BigInt.zero; - final display = ref.watch(txAmountDisplayProvider)(planck, withSignPrefix: false, isSend: false, quanDecimals: 4); + final display = ref.watch(txAmountDisplayProvider)( + widget.amountPlanck, + withSignPrefix: false, + isSend: false, + quanDecimals: 4, + ); return ScaffoldBase( appBar: V2AppBar(title: _isPaid ? 'Payment Received' : 'Scan to Pay'), @@ -176,7 +178,7 @@ class _PosQrScreenState extends ConsumerState { if (active == null) return const Center(child: Text('No active account')); _request ??= PosService( formattingService: formattingService, - ).createPaymentRequest(accountId: active.account.accountId, amountPlanck: planck); + ).createPaymentRequest(accountId: active.account.accountId, amountPlanck: widget.amountPlanck); if (_isPaid) return _buildPaidContent(colors, text, display.primaryAmount); return _buildQrContent(_request!, colors, text, display); }, 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 4f2c4e19..672c4a31 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -65,12 +65,14 @@ class _InputAmountScreenState extends ConsumerState { super.initState(); assert(widget.recipientAddress.trim().isNotEmpty, 'InputAmountScreen requires a recipient'); _amountFocus.addListener(_onAmountFocusChanged); - if (widget.initialAmount != null) { - final isFlipped = ref.read(isCurrencyFlippedProvider); - final parsed = _amountInputLogic.parsePaymentUrlAmount(widget.initialAmount!, isFlipped: isFlipped); - if (parsed.planck > BigInt.zero) { - _amount = parsed.planck; - _amountController.text = parsed.displayText; + 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) { @@ -116,7 +118,7 @@ class _InputAmountScreenState extends ConsumerState { } void _onAmountChanged(String _) { - final isFlipped = ref.read(isCurrencyFlippedProvider); + final isFlipped = widget.isPayMode ? false : ref.read(isCurrencyFlippedProvider); try { setState(() => _amount = _amountInputLogic.onAmountChanged(value: _amountController.text, isFlipped: isFlipped)); } on InvalidNumberInputException catch (e, stack) { @@ -337,7 +339,8 @@ class _InputAmountScreenState extends ConsumerState { } Widget _amountCenter(AppColorsV2 colors, AppTextTheme text) { - final isFlipped = ref.watch(isCurrencyFlippedProvider); + 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)( @@ -393,26 +396,28 @@ class _InputAmountScreenState extends ConsumerState { 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(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '≈ ${display.secondaryAmount}', + style: text.paragraph?.copyWith( + color: colors.textTertiary, + fontFamily: AppTextTheme.fontFamilySecondary, + ), ), - ), - const SizedBox(width: 8), - QuantusIconButton.circular( - icon: Icons.swap_vert, - onTap: _toggleFlip, - isActive: display.isFlipped, - size: IconButtonSize.small, - ), - ], - ), + const SizedBox(width: 8), + QuantusIconButton.circular( + icon: Icons.swap_vert, + onTap: _toggleFlip, + isActive: display.isFlipped, + size: IconButtonSize.small, + ), + ], + ), + ], ], ), ); 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 024795f2..18f73b63 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -4,11 +4,9 @@ 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/providers/currency_display_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/utils/amount_input_logic.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'; @@ -133,17 +131,9 @@ class _SelectRecipientScreenState extends ConsumerState { if (scanResult == null || !mounted) return; final payment = PaymentIntent.tryParseUrl(scanResult); if (payment != null) { - final amountInputLogic = AmountInputLogic( - exchangeRateService: ref.read(exchangeRateServiceProvider), - selectedFiat: ref.read(selectedFiatCurrencyProvider), - localeConfig: ref.read(localeNumberConfigProvider), - formattingService: ref.read(numberFormattingServiceProvider), - ); - final isFlipped = ref.read(isCurrencyFlippedProvider); - final parsed = amountInputLogic.parsePaymentUrlAmount(payment.amount, isFlipped: isFlipped); setState(() { _recipientController.text = payment.to; - _amountController.text = parsed.displayText; + _amountController.text = payment.amount; _isPayMode = true; }); } else { diff --git a/mobile-app/test/unit/amount_input_logic_test.dart b/mobile-app/test/unit/amount_input_logic_test.dart index 448b0552..85a04294 100644 --- a/mobile-app/test/unit/amount_input_logic_test.dart +++ b/mobile-app/test/unit/amount_input_logic_test.dart @@ -77,39 +77,5 @@ void main() { final logic = createLogic(); expect(logic.formatQuanAmount(BigInt.zero), ''); }); - - test('parsePaymentUrlAmount localizes canonical wire amount for payer locale', () { - final logic = createLogic(); - final result = logic.parsePaymentUrlAmount('1.5', isFlipped: false); - - expect(result.planck, BigInt.from(1500000000000)); - expect(result.displayText, '1.5'); - }); - - test('parsePaymentUrlAmount localizes legacy comma-decimal wire amount', () { - final logic = createLogic(); - final result = logic.parsePaymentUrlAmount('1,5', isFlipped: false); - - expect(result.planck, BigInt.from(1500000000000)); - expect(result.displayText, '1.5'); - }); - - test('parsePaymentUrlAmount displays comma decimal for comma-locale payers', () { - localeConfig = LocaleNumberConfig.commaDecimal; - formattingService = NumberFormattingService(localeConfig: localeConfig); - final logic = createLogic(); - final result = logic.parsePaymentUrlAmount('1.5', isFlipped: false); - - expect(result.planck, BigInt.from(1500000000000)); - expect(result.displayText, '1,5'); - }); - - test('parsePaymentUrlAmount returns empty display text for zero amount', () { - final logic = createLogic(); - final result = logic.parsePaymentUrlAmount('', isFlipped: false); - - expect(result.planck, BigInt.zero); - expect(result.displayText, ''); - }); }); }