diff --git a/mobile-app/assets/copy_icon.svg b/mobile-app/assets/copy_icon.svg deleted file mode 100644 index 0a5caf533..000000000 --- a/mobile-app/assets/copy_icon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/mobile-app/assets/lock_icon.svg b/mobile-app/assets/lock_icon.svg deleted file mode 100644 index 73e51aed1..000000000 --- a/mobile-app/assets/lock_icon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/mobile-app/lib/shared/utils/amount_input_logic.dart b/mobile-app/lib/shared/utils/amount_input_logic.dart new file mode 100644 index 000000000..db0d6d4c3 --- /dev/null +++ b/mobile-app/lib/shared/utils/amount_input_logic.dart @@ -0,0 +1,88 @@ +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/models/fiat_currency.dart'; +import 'package:resonance_network_wallet/services/exchange_rate_service.dart'; + +class ToggledInputResult { + final String text; + final BigInt amount; + + ToggledInputResult({required this.text, required this.amount}); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ToggledInputResult && runtimeType == other.runtimeType && text == other.text && amount == other.amount; + + @override + int get hashCode => text.hashCode ^ amount.hashCode; +} + +class AmountInputLogic { + final ExchangeRateService exchangeRateService; + final FiatCurrency selectedFiat; + final LocaleNumberConfig localeConfig; + final NumberFormattingService formattingService; + + AmountInputLogic({ + required this.exchangeRateService, + required this.selectedFiat, + required this.localeConfig, + required this.formattingService, + }); + + /// 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. + String quanToFiatString(BigInt quanAmount) { + if (quanAmount == BigInt.zero) return ''; + final fiatValue = exchangeRateService.quanRawToFiat(quanAmount, selectedFiat, AppConstants.decimals); + final canonical = fiatValue.toStringAsFixed(selectedFiat.decimals); + return localeConfig.localize(canonical, addGroupingSeparators: false); + } + + /// Parses a locale-formatted fiat input string and returns the equivalent + /// raw QUAN [BigInt] scaled by [AppConstants.decimals]. + /// + /// Throws [InvalidNumberInputException] when [fiatText] cannot be parsed. + BigInt fiatStringToQuan(String fiatText) { + if (fiatText.isEmpty) return BigInt.zero; + final fiatDecimal = localeConfig.parseDecimal(fiatText); + return exchangeRateService.fiatToQuanRaw(fiatDecimal, selectedFiat, AppConstants.decimals); + } + + /// Parses a QUAN amount string. + BigInt parseQuanAmount(String text) { + if (text.isEmpty) return BigInt.zero; + return formattingService.parseAmount(text) ?? BigInt.zero; + } + + /// Formats a QUAN amount for display in an input field. + String formatQuanAmount(BigInt amount) { + if (amount == BigInt.zero) return ''; + return formattingService.formatBalance(amount, maxDecimals: AppConstants.decimals, addThousandsSeparators: false); + } + + /// Returns the new input string and amount when toggling between QUAN and Fiat. + ToggledInputResult getToggledInput({required bool wasFlipped, required BigInt currentAmount}) { + if (wasFlipped) { + // Fiat -> QUAN: The user was looking at a fiat amount. + // We already have currentAmount which was calculated from that fiat amount. + final text = formatQuanAmount(currentAmount); + return ToggledInputResult(text: text, amount: currentAmount); + } else { + // QUAN -> Fiat: re-parse amount from the rounded fiat string so + // the displayed value and amount stay in sync. + final text = quanToFiatString(currentAmount); + final newAmount = currentAmount == BigInt.zero ? BigInt.zero : fiatStringToQuan(text); + return ToggledInputResult(text: text, amount: newAmount); + } + } + + /// Handles amount change and returns the updated BigInt amount. + BigInt onAmountChanged({required String value, required bool isFlipped}) { + if (isFlipped) { + return fiatStringToQuan(value); + } else { + return parseQuanAmount(value); + } + } +} diff --git a/mobile-app/lib/v2/components/quantus_qr.dart b/mobile-app/lib/v2/components/quantus_qr.dart new file mode 100644 index 000000000..65c96b87a --- /dev/null +++ b/mobile-app/lib/v2/components/quantus_qr.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; + +class QuantusQr extends StatelessWidget { + final String accountId; + + const QuantusQr({super.key, required this.accountId}); + + @override + Widget build(BuildContext context) { + final qrSize = 267.0; + final qrLogoSize = 64.0; + + return Container( + decoration: BoxDecoration( + border: Border.all(color: context.colors.textTertiary, width: 1), + borderRadius: BorderRadius.circular(16), + ), + width: qrSize, + height: qrSize, + child: QrImageView( + data: accountId, + errorCorrectionLevel: QrErrorCorrectLevel.M, + embeddedImage: const AssetImage('assets/v2/uppercase_q_black_bg.png'), + embeddedImageStyle: QrEmbeddedImageStyle(size: Size(qrLogoSize, qrLogoSize)), + version: QrVersions.auto, + size: qrSize, + padding: const EdgeInsets.all(16), + eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.white), + dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.white), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 05ba37a28..5f2cd01aa 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -88,14 +88,12 @@ class _HomeScreenState extends ConsumerState { showSharedAddressActionSheet(context, shared); }); - final isPosMode = ref.watch(posModeProvider); - final balanceAsync = ref.watch(balanceProvider); final accountAsync = ref.watch(activeAccountProvider); final txAsync = ref.watch(activeAccountTransactionsProvider(TransactionFilter.all)); final colors = context.colors; final text = context.themeText; - Widget screen = accountAsync.when( + return accountAsync.when( loading: () => const ScaffoldBase(mainContent: Center(child: Loader())), error: (e, _) => ScaffoldBase( mainContent: Center( @@ -111,46 +109,12 @@ class _HomeScreenState extends ConsumerState { slivers: [ _buildContent(active, colors, text), ActivitySection(txAsync: txAsync, activeAccount: active.account, onRetry: _refresh), - SizedBox(height: isPosMode ? 120 : 58), + const SizedBox(height: 58), ], - bottomContent: balanceAsync - .whenData( - (balance) => balance == BigInt.zero - ? ScaffoldBaseBottomContent( - child: QuantusButton.simple( - label: 'Get Testnet Tokens ↗', - onTap: () => launchXPost(AppConstants.faucetUrl), - ), - ) - : null, - ) - .value, + bottomContent: _buildBottomContent(), ); }, ); - - if (!isPosMode) return screen; - - return Stack( - children: [ - screen, - Positioned( - left: 24, - right: 24, - bottom: MediaQuery.of(context).padding.bottom + 24, - child: Material(color: Colors.transparent, child: _buildPosButton(colors, text)), - ), - ], - ); - } - - Widget _buildPosButton(AppColorsV2 colors, AppTextTheme text) { - return QuantusButton.simple( - label: 'New Charge', - variant: ButtonVariant.success, - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PosAmountScreen())), - textStyle: text.smallTitle?.copyWith(fontWeight: FontWeight.w700, fontSize: 20, decoration: TextDecoration.none), - ); } Widget _buildContent(DisplayAccount active, AppColorsV2 colors, AppTextTheme text) { @@ -173,6 +137,33 @@ class _HomeScreenState extends ConsumerState { ); } + Widget? _buildBottomContent() { + final enablePos = ref.watch(posModeProvider); + final balanceAsync = ref.watch(balanceProvider); + + if (enablePos) { + return ScaffoldBaseBottomContent( + child: QuantusButton.simple( + label: 'Charge', + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PosAmountScreen())), + ), + ); + } + + return balanceAsync + .whenData( + (balance) => balance == BigInt.zero + ? ScaffoldBaseBottomContent( + child: QuantusButton.simple( + label: 'Get Testnet Tokens ↗', + onTap: () => launchXPost(AppConstants.faucetUrl), + ), + ) + : null, + ) + .value; + } + Widget _buildTopBar() { final isBalanceHidden = ref.watch(isBalanceHiddenProvider); @@ -247,43 +238,37 @@ class _HomeScreenState extends ConsumerState { onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SelectRecipientScreen())), ); - if (!enableSwap) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(width: 151, child: receiveCard), - const SizedBox(width: 20), - SizedBox(width: 151, child: sendCard), - ], - ); + final swapCard = _actionCard( + iconAsset: 'assets/v2/action_swap.svg', + label: 'Swap', + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SwapScreen())), + ); + + final List children = []; + + children.add(receiveCard); + children.add(const SizedBox(width: 15)); + children.add(sendCard); + + if (enableSwap) { + children.add(const SizedBox(width: 15)); + children.add(swapCard); } - return Row( - children: [ - Expanded(child: receiveCard), - const SizedBox(width: 15), - Expanded(child: sendCard), - const SizedBox(width: 15), - Expanded( - child: _actionCard( - iconAsset: 'assets/v2/action_swap.svg', - label: 'Swap', - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SwapScreen())), - ), - ), - ], - ); + return Row(children: children); } Widget _actionCard({required String iconAsset, required String label, required VoidCallback onTap}) { - return QuantusButton.simple( - label: label, - onTap: onTap, - icon: SvgPicture.asset(iconAsset, width: 24, height: 24), - iconPlacement: IconPlacement.top, - padding: const EdgeInsets.all(14), - variant: ButtonVariant.secondary, - textStyle: context.themeText.paragraph?.copyWith(color: context.colors.textPrimary.useOpacity(0.8)), + return Expanded( + child: QuantusButton.simple( + label: label, + onTap: onTap, + icon: SvgPicture.asset(iconAsset, width: 24, height: 24), + iconPlacement: IconPlacement.top, + padding: const EdgeInsets.all(14), + variant: ButtonVariant.secondary, + textStyle: context.themeText.paragraph?.copyWith(color: context.colors.textPrimary.useOpacity(0.8)), + ), ); } } 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 af28d9310..6e519630d 100644 --- a/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart @@ -1,9 +1,15 @@ import 'package:flutter/material.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/currency_display_provider.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/shared/extensions/toaster_extensions.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/pos/pos_qr_screen.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; @@ -17,129 +23,155 @@ class PosAmountScreen extends ConsumerStatefulWidget { } class _PosAmountScreenState extends ConsumerState { - String _input = '0'; - LocaleNumberConfig get _localeConfig => ref.read(localeNumberConfigProvider); + final _amountController = TextEditingController(); + final _amountFocus = FocusNode(); + BigInt _amount = BigInt.zero; - void _onDigit(String digit) { - final sep = _localeConfig.decimalSeparator; - final oldText = _input == '0' && digit != sep ? '' : _input; - final newText = oldText + digit; + AmountInputLogic get _amountInputLogic => AmountInputLogic( + exchangeRateService: ref.read(exchangeRateServiceProvider), + selectedFiat: ref.read(selectedFiatCurrencyProvider), + localeConfig: ref.read(localeNumberConfigProvider), + formattingService: ref.read(numberFormattingServiceProvider), + ); - final oldValue = TextEditingValue(text: oldText); - final newValue = TextEditingValue(text: newText); - - final filter = DecimalInputFilter(localeConfig: _localeConfig); - final formatted = filter.formatEditUpdate(oldValue, newValue); - - setState(() { - _input = formatted.text.isEmpty ? '0' : formatted.text; - }); + @override + void dispose() { + _amountController.dispose(); + _amountFocus.dispose(); + super.dispose(); } - void _onBackspace() { - setState(() { - if (_input.length <= 1) { - _input = '0'; - } else { - _input = _input.substring(0, _input.length - 1); - } - }); + void _onAmountChanged(String _) { + final isFlipped = 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: 'Please enter a valid amount'); + return; + } } - void _onClear() => setState(() => _input = '0'); - void _onCharge() { - final formattingService = ref.watch(numberFormattingServiceProvider); - final amount = formattingService.parseAmount(_input); - if (amount == null || amount <= BigInt.zero) return; - Navigator.push(context, MaterialPageRoute(builder: (_) => PosQrScreen(amount: _input))); + if (_amount <= BigInt.zero) return; + final quanString = _amountInputLogic.formatQuanAmount(_amount); + Navigator.push(context, MaterialPageRoute(builder: (_) => PosQrScreen(amount: quanString))); } - bool get _isValid { - final formattingService = ref.watch(numberFormattingServiceProvider); - final amount = formattingService.parseAmount(_input); - return amount != null && amount > BigInt.zero; + 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; + }); } + bool get _isValid => _amount > BigInt.zero; + @override Widget build(BuildContext context) { final colors = context.colors; final text = context.themeText; + final primaryAmount = ref + .watch(txAmountDisplayProvider)(_amount, withSignPrefix: false, isSend: true) + .primaryAmount; return ScaffoldBase( appBar: const V2AppBar(title: 'New Charge'), - mainContent: Column( - children: [ - Expanded( - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - '$_input ${AppConstants.tokenSymbol}', - style: text.extraLargeTitle?.copyWith( - color: colors.textPrimary, - fontSize: 56, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - _buildKeypad(colors, text), - const SizedBox(height: 16), - _buildChargeButton(colors, text), - const SizedBox(height: 24), - ], - ), + mainContent: _amountCenter(colors, text), + bottomContent: _bottomContent(colors, text, primaryAmount), ); } - Widget _buildKeypad(AppColorsV2 colors, AppTextTheme text) { - final decimalSeparator = _localeConfig.decimalSeparator; - final keys = [ - ['1', '2', '3'], - ['4', '5', '6'], - ['7', '8', '9'], - [decimalSeparator, '0', 'backspace'], - ]; - - return Column( - children: keys.map((row) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: row.map((key) => _buildKey(key, colors, text)).toList(), - ), - ); - }).toList(), + Widget _amountCenter(AppColorsV2 colors, AppTextTheme text) { + final isFlipped = ref.watch(isCurrencyFlippedProvider); + final selectedFiat = ref.watch(selectedFiatCurrencyProvider); + final display = ref.watch(txAmountDisplayProvider)( + _amount, + withSignPrefix: false, + quanDecimals: 4, + isSend: true, + withQuanSymbol: false, ); - } - Widget _buildKey(String key, AppColorsV2 colors, AppTextTheme text) { - return Expanded( - child: GestureDetector( - onTap: () => key == 'backspace' ? _onBackspace() : _onDigit(key), - onLongPress: key == 'backspace' ? _onClear : null, - behavior: HitTestBehavior.opaque, - child: Container( - height: 60, - alignment: Alignment.center, - child: key == 'backspace' - ? Icon(Icons.backspace_outlined, color: colors.textPrimary, size: 28) - : Text(key, style: text.mediumTitle?.copyWith(color: colors.textPrimary, fontSize: 28)), + 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: ref.read(localeNumberConfigProvider), 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( + 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, + ), + ), + const SizedBox(width: 8), + QuantusIconButton.circular( + icon: Icons.swap_vert, + onTap: _toggleFlip, + isActive: display.isFlipped, + size: IconButtonSize.small, + ), + ], + ), + ], + ), + ); } - Widget _buildChargeButton(AppColorsV2 colors, AppTextTheme text) { - final disabled = !_isValid; - return QuantusButton.simple( - label: _isValid ? 'Charge $_input ${AppConstants.tokenSymbol}' : 'Enter Amount', - onTap: _onCharge, - isDisabled: disabled, - textStyle: text.smallTitle?.copyWith(fontWeight: FontWeight.w700), + Widget _bottomContent(AppColorsV2 colors, AppTextTheme text, String amountDisplay) { + final label = _amount > BigInt.zero ? 'Charge $amountDisplay' : 'Enter Amount'; + + return ScaffoldBaseBottomContent( + child: QuantusButton.simple(label: label, onTap: _onCharge, isDisabled: !_isValid), ); } } 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 8aa367f95..ef3aa7d86 100644 --- a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart @@ -2,15 +2,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:qr_flutter/qr_flutter.dart'; import 'package:quantus_sdk/quantus_sdk.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/pending_transactions_provider.dart'; 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/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/quantus_qr.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/services/tx_watch_service.dart'; import 'package:resonance_network_wallet/v2/screens/pos/pos_amount_screen.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; @@ -34,6 +38,8 @@ class _PosQrScreenState extends ConsumerState { Timer? _startTimer; Timer? _timeoutTimer; TxWatchTransfer? _paidTransfer; + DateTime? _paidAt; + String? _senderCheckphrase; bool _watching = false; String? _watchError; bool get _isPaid => _paidTransfer != null; @@ -87,7 +93,13 @@ class _PosQrScreenState extends ConsumerState { ); ref.read(pendingTransactionsProvider.notifier).add(pendingTx); ref.read(pendingTransactionPollingServiceProvider).startPolling(pendingTx); - if (mounted) setState(() => _paidTransfer = tx); + if (mounted) { + setState(() { + _paidTransfer = tx; + _paidAt = DateTime.now(); + }); + _loadSenderCheckphrase(tx.from); + } }, onError: (e) { _txWatch.dispose(); @@ -112,6 +124,12 @@ class _PosQrScreenState extends ConsumerState { }); } + Future _loadSenderCheckphrase(String address) async { + final checksumService = ref.read(humanReadableChecksumServiceProvider); + final checkphrase = await checksumService.getHumanReadableName(address); + if (mounted) setState(() => _senderCheckphrase = checkphrase); + } + @override void dispose() { _startTimer?.cancel(); @@ -120,11 +138,33 @@ class _PosQrScreenState extends ConsumerState { super.dispose(); } + void _toggleFlip() { + ref.read(isCurrencyFlippedProvider.notifier).toggle(); + } + + void _done() { + Navigator.of(context).popUntil((route) => route.isFirst); + } + + void _newCharge() { + Navigator.of(context).popUntil((route) => route.isFirst); + 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 colors = context.colors; 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); return ScaffoldBase( appBar: V2AppBar(title: _isPaid ? 'Payment Received' : 'Scan to Pay'), @@ -136,101 +176,235 @@ 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); - if (_isPaid) return _buildPaidContent(colors, text); - return _buildQrContent(_request!, colors, text); + if (_isPaid) return _buildPaidContent(colors, text, display.primaryAmount); + return _buildQrContent(_request!, colors, text, display); }, ), + bottomContent: ScaffoldBaseBottomContent(child: _isPaid ? _buildPaidButtons() : _buildQrButton()), + ); + } + + Widget _buildQrButton() { + return QuantusButton.simple(label: 'New Charge', onTap: _newCharge, variant: ButtonVariant.primary); + } + + Widget _buildPaidButtons() { + final padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 20); + + return Row( + spacing: 16, + children: [ + Expanded( + child: QuantusButton.simple(padding: padding, label: 'Done', onTap: _done, variant: ButtonVariant.secondary), + ), + Expanded( + child: QuantusButton.simple( + padding: padding, + label: 'New Charge', + onTap: _newCharge, + variant: ButtonVariant.primary, + ), + ), + ], ); } - Widget _buildPaidContent(AppColorsV2 colors, AppTextTheme text) { + Widget _buildPaidContent(AppColorsV2 colors, AppTextTheme text, String amountDisplay) { + final transfer = _paidTransfer!; + final formattedAddress = AddressFormattingService.formatAddress(transfer.from.trim()); + return Column( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Spacer(), - Icon(Icons.check_circle_rounded, color: colors.accentGreen, size: 96), - const SizedBox(height: 24), - Text('Paid', style: text.extraLargeTitle?.copyWith(color: colors.accentGreen, fontSize: 48)), - const SizedBox(height: 16), + const SizedBox(height: 40), + _buildSuccessCircle(colors), + const SizedBox(height: 32), Text( - '${widget.amount} ${AppConstants.tokenSymbol}', - style: text.mediumTitle?.copyWith(color: colors.textSecondary), + '$amountDisplay received', + style: text.smallTitle?.copyWith(color: colors.textLightGray, fontSize: 32, fontWeight: FontWeight.w400), + textAlign: TextAlign.center, ), + const SizedBox(height: 4), + if (_paidAt != null) + Text( + _formatPaidAt(_paidAt!), + style: text.smallParagraph?.copyWith(color: colors.textTertiary, letterSpacing: 0.7), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + _buildFromSection(colors, text, formattedAddress), const Spacer(), - QuantusButton.simple(label: 'Done', onTap: _newCharge, variant: ButtonVariant.primary), - const SizedBox(height: 24), + _buildExplorerLink(colors, text), + const SizedBox(height: 16), ], ); } - void _newCharge() { - Navigator.of(context).popUntil((route) => route.isFirst); - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PosAmountScreen())); + Widget _buildSuccessCircle(AppColorsV2 colors) { + return Container( + width: 78, + height: 78, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: colors.success, width: 1.8), + ), + child: Center(child: Icon(Icons.check, color: colors.success, size: 32)), + ); } - Widget _buildQrContent(PosPaymentRequest request, AppColorsV2 colors, AppTextTheme text) { + Widget _buildFromSection(AppColorsV2 colors, AppTextTheme text, String formattedAddress) { return Column( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Spacer(), Text( - '${request.amount} ${AppConstants.tokenSymbol}', - style: text.extraLargeTitle?.copyWith(color: colors.textPrimary, fontSize: 40), + 'From:', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + textAlign: TextAlign.center, ), - const SizedBox(height: 32), - _buildQrCode(request.paymentUrl, colors), - const SizedBox(height: 12), - Text('Ref: ${request.refId}', style: text.detail?.copyWith(color: colors.textTertiary)), + const SizedBox(height: 16), + if (_senderCheckphrase != null) + Text( + _senderCheckphrase!, + style: text.smallParagraph?.copyWith(color: colors.checksum), + textAlign: TextAlign.center, + ) + else + Text( + '...', + style: text.smallParagraph?.copyWith(color: colors.checksum), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + formattedAddress.toLowerCase(), + style: text.smallParagraph?.copyWith( + color: colors.textPrimary, + fontFamily: AppTextTheme.fontFamilySecondary, + fontWeight: FontWeight.w500, + height: 1.35, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildExplorerLink(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('View in Explorer ↗', style: text.smallParagraph?.copyWith(color: colors.textTertiary)), + ), + ); + } + + Widget _buildQrContent( + PosPaymentRequest request, + AppColorsV2 colors, + AppTextTheme text, + CurrencyDisplayState display, + ) { + return Column( + children: [ + _buildAmountSection(colors, text, display), + const SizedBox(height: 16), + QuantusQr(accountId: request.paymentUrl), const Spacer(), - QuantusButton.simple(label: 'New Charge', onTap: _newCharge, variant: ButtonVariant.secondary), + if (!_watching && _watchError != null) _buildErrorSection(colors, text), + if (_watching) _buildWaitingPill(colors, text), const SizedBox(height: 16), - _buildWaitingButton(colors, text), - const SizedBox(height: 24), ], ); } - Widget _buildWaitingButton(AppColorsV2 colors, AppTextTheme text) { - if (_watching) { - return QuantusButton( - variant: ButtonVariant.primary, - onTap: () {}, - child: Row( + Widget _buildAmountSection(AppColorsV2 colors, AppTextTheme text, CurrencyDisplayState display) { + return Column( + children: [ + Text( + display.primaryAmount, + style: text.totalMinedBlocks?.copyWith(color: colors.textPrimary, letterSpacing: -2.77), + ), + const SizedBox(height: 8), + Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Loader(), - const SizedBox(width: 10), - Text('Waiting for payment', style: text.smallTitle?.copyWith(color: colors.textSecondary, fontSize: 16)), + 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, + ), ], ), - ); - } + ], + ); + } - return Column( - children: [ - if (_watchError != null) ...[ - Text('Network Error', style: text.detail?.copyWith(color: colors.textError)), - const SizedBox(height: 8), - QuantusButton.simple(label: 'Try Again', onTap: _startWatching, variant: ButtonVariant.secondary), - const SizedBox(height: 12), + Widget _buildWaitingPill(AppColorsV2 colors, AppTextTheme text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 25, vertical: 9), + decoration: BoxDecoration( + color: colors.toasterBackground, + border: Border.all(color: colors.toasterBorder), + borderRadius: BorderRadius.circular(35), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Loader(size: 14, color: colors.textMuted), + const SizedBox(width: 9), + Text('Waiting for payment', style: text.detail?.copyWith(color: colors.textMuted)), ], - QuantusButton.simple(label: 'Done', onTap: _newCharge, variant: ButtonVariant.primary), - ], + ), ); } - Widget _buildQrCode(String data, AppColorsV2 colors) { - return Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: QrImageView( - data: data, - version: QrVersions.auto, - size: 280, - padding: const EdgeInsets.all(16), - backgroundColor: Colors.white, - eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black), - dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.black), + Widget _buildErrorSection(AppColorsV2 colors, AppTextTheme text) { + return Column( + children: [ + Text('Network Error', style: text.detail?.copyWith(color: colors.textError)), + const SizedBox(height: 8), + QuantusButton.simple( + label: 'Try Again', + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 8), + onTap: _startWatching, + variant: ButtonVariant.secondary, ), - ), + ], ); } + + String _formatPaidAt(DateTime dt) { + final hour = dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour); + final minute = dt.minute.toString().padLeft(2, '0'); + final ampm = dt.hour >= 12 ? 'pm' : 'am'; + final ordinal = _ordinalSuffix(dt.day); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + final month = months[dt.month - 1]; + final year = dt.year.toString().substring(2); + return "At $hour:$minute$ampm, ${dt.day}$ordinal $month'$year"; + } + + String _ordinalSuffix(int day) { + if (day >= 11 && day <= 13) return 'th'; + switch (day % 10) { + case 1: + return 'st'; + case 2: + return 'nd'; + case 3: + return 'rd'; + default: + return 'th'; + } + } } diff --git a/mobile-app/lib/v2/screens/receive/receive_screen.dart b/mobile-app/lib/v2/screens/receive/receive_screen.dart index 349eb02d3..6403a8d62 100644 --- a/mobile-app/lib/v2/screens/receive/receive_screen.dart +++ b/mobile-app/lib/v2/screens/receive/receive_screen.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:qr_flutter/qr_flutter.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/v2/components/address_details_card.dart'; import 'package:resonance_network_wallet/v2/components/loader.dart'; +import 'package:resonance_network_wallet/v2/components/quantus_qr.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/share_account_button.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -148,32 +148,10 @@ class QrCodeTab extends StatelessWidget { @override Widget build(BuildContext context) { - final qrSize = 267.0; - final qrLogoSize = 64.0; - return Expanded( child: Column( children: [ - Container( - decoration: BoxDecoration( - border: Border.all(color: context.colors.textTertiary, width: 1), - borderRadius: BorderRadius.circular(16), - ), - width: qrSize, - height: qrSize, - child: QrImageView( - data: accountId, - errorCorrectionLevel: QrErrorCorrectLevel.M, - embeddedImage: const AssetImage('assets/v2/uppercase_q_black_bg.png'), - embeddedImageStyle: QrEmbeddedImageStyle(size: Size(qrLogoSize, qrLogoSize)), - version: QrVersions.auto, - size: qrSize, - padding: const EdgeInsets.all(16), - eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.white), - dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.white), - ), - ), - + QuantusQr(accountId: accountId), const SizedBox(height: 12), Text( checksum, 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 5d648218a..05ca1c8c6 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -15,6 +15,7 @@ import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_cont import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.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_icon_button.dart'; @@ -52,7 +53,12 @@ class _InputAmountScreenState extends ConsumerState { int _blockHeight = 0; bool _isFetchingFee = true; - LocaleNumberConfig get _localeConfig => ref.read(localeNumberConfigProvider); + AmountInputLogic get _amountInputLogic => AmountInputLogic( + exchangeRateService: ref.read(exchangeRateServiceProvider), + selectedFiat: ref.read(selectedFiatCurrencyProvider), + localeConfig: ref.read(localeNumberConfigProvider), + formattingService: ref.read(numberFormattingServiceProvider), + ); @override void initState() { @@ -61,16 +67,14 @@ class _InputAmountScreenState extends ConsumerState { _amountFocus.addListener(_onAmountFocusChanged); if (widget.initialAmount != null) { final isFlipped = ref.read(isCurrencyFlippedProvider); - final formattingService = ref.read(numberFormattingServiceProvider); if (!isFlipped) { - final parsed = formattingService.parseAmount(widget.initialAmount!); - _amount = parsed ?? BigInt.zero; + _amount = _amountInputLogic.parseQuanAmount(widget.initialAmount!); _amountController.text = widget.initialAmount!; } else { - final parsed = formattingService.parseAmount(widget.initialAmount!); - if (parsed != null && parsed > BigInt.zero) { + final parsed = _amountInputLogic.parseQuanAmount(widget.initialAmount!); + if (parsed > BigInt.zero) { _amount = parsed; - _amountController.text = _quanToFiatString(parsed); + _amountController.text = _amountInputLogic.quanToFiatString(parsed); } } } @@ -118,19 +122,12 @@ class _InputAmountScreenState extends ConsumerState { void _onAmountChanged(String _) { final isFlipped = ref.read(isCurrencyFlippedProvider); - if (isFlipped) { - try { - final convertedAmount = _fiatStringToQuan(_amountController.text); - setState(() => _amount = convertedAmount); - } on InvalidNumberInputException catch (e, stack) { - debugPrint('Fiat→QUAN parse failed: $e\n$stack'); - context.showErrorToaster(message: 'Please enter a valid amount'); - return; - } - } else { - final formattingService = ref.read(numberFormattingServiceProvider); - final parsed = formattingService.parseAmount(_amountController.text); - setState(() => _amount = parsed ?? BigInt.zero); + 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: 'Please enter a valid amount'); + return; } if (_amount > BigInt.zero) _feeDebouncer.run(_fetchFee); } @@ -185,34 +182,13 @@ class _InputAmountScreenState extends ConsumerState { /// 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. - String _quanToFiatString(BigInt quanAmount) { - final xRate = ref.read(exchangeRateServiceProvider); - final selectedFiat = ref.read(selectedFiatCurrencyProvider); - final fiatValue = xRate.quanRawToFiat(quanAmount, selectedFiat, AppConstants.decimals); - final canonical = fiatValue.toStringAsFixed(selectedFiat.decimals); - return _localeConfig.localize(canonical, addGroupingSeparators: false); - } - - /// Parses a locale-formatted fiat input string and returns the equivalent - /// raw QUAN [BigInt] scaled by [AppConstants.decimals]. - /// - /// Throws [InvalidNumberInputException] when [fiatText] cannot be parsed. - BigInt _fiatStringToQuan(String fiatText) { - if (fiatText.isEmpty) return BigInt.zero; - final fiatDecimal = _localeConfig.parseDecimal(fiatText); - final xRate = ref.read(exchangeRateServiceProvider); - final selectedFiat = ref.read(selectedFiatCurrencyProvider); - return xRate.fiatToQuanRaw(fiatDecimal, selectedFiat, AppConstants.decimals); - } - void _setMax() { final balance = ref.read(effectiveMaxBalanceProvider).value ?? BigInt.zero; final max = SendScreenLogic.calculateMaxSendableAmount(balance: balance, networkFee: _networkFee); final isFlipped = ref.read(isCurrencyFlippedProvider); - final formattingService = ref.read(numberFormattingServiceProvider); _amountController.text = isFlipped - ? _quanToFiatString(max) - : formattingService.formatBalance(max, maxDecimals: AppConstants.decimals, addThousandsSeparators: false); + ? _amountInputLogic.quanToFiatString(max) + : _amountInputLogic.formatQuanAmount(max); setState(() => _amount = max); if (max > BigInt.zero) _fetchFee(); } @@ -220,26 +196,13 @@ class _InputAmountScreenState extends ConsumerState { Future _toggleFlip() async { final wasFlipped = ref.read(isCurrencyFlippedProvider); await ref.read(isCurrencyFlippedProvider.notifier).toggle(); - final formattingService = ref.read(numberFormattingServiceProvider); - // Anchor the amount to the current primary value before flipping. - // If we were in fiat mode, the 'canonical' amount is the fiat value. - // If we were in QUAN mode, the 'canonical' amount is the QUAN value. - if (wasFlipped) { - // Fiat -> QUAN: The user was looking at a fiat amount. - // We already have _amount which was calculated from that fiat amount. - // No change needed to _amount, just update the controller. - _amountController.text = _amount == BigInt.zero - ? '' - : formattingService.formatBalance(_amount, maxDecimals: AppConstants.decimals, addThousandsSeparators: false); - } else { - // QUAN -> Fiat: re-parse _amount from the rounded fiat string so - // the displayed value and _amount stay in sync. - _amountController.text = _amount == BigInt.zero ? '' : _quanToFiatString(_amount); - if (_amount != BigInt.zero) { - _amount = _fiatStringToQuan(_amountController.text); - } - } + final result = _amountInputLogic.getToggledInput(wasFlipped: wasFlipped, currentAmount: _amount); + + setState(() { + _amountController.text = result.text; + _amount = result.amount; + }); } Future _openReview() async { diff --git a/mobile-app/test/unit/amount_input_logic_test.dart b/mobile-app/test/unit/amount_input_logic_test.dart new file mode 100644 index 000000000..85a04294e --- /dev/null +++ b/mobile-app/test/unit/amount_input_logic_test.dart @@ -0,0 +1,81 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/models/fiat_currency.dart'; +import 'package:resonance_network_wallet/services/exchange_rate_service.dart'; +import 'package:resonance_network_wallet/shared/utils/amount_input_logic.dart'; + +void main() { + group('AmountInputLogic', () { + late ExchangeRateService exchangeRateService; + late LocaleNumberConfig localeConfig; + late NumberFormattingService formattingService; + + setUp(() { + exchangeRateService = ExchangeRateService(rates: {'USD': Decimal.one}); + localeConfig = LocaleNumberConfig.dotDecimal; + formattingService = NumberFormattingService(localeConfig: localeConfig); + }); + + AmountInputLogic createLogic({FiatCurrency? selectedFiat}) { + return AmountInputLogic( + exchangeRateService: exchangeRateService, + selectedFiat: selectedFiat ?? FiatCurrency.usd, + localeConfig: localeConfig, + formattingService: formattingService, + ); + } + + test('quanToFiatString converts correctly', () { + final logic = createLogic(); + final amount = BigInt.from(1000000000000); // 1.0 QUAN + expect(logic.quanToFiatString(amount), '1.00'); + }); + + test('fiatStringToQuan parses correctly', () { + final logic = createLogic(); + final result = logic.fiatStringToQuan('1.00'); + expect(result, BigInt.from(1000000000000)); + }); + + test('getToggledInput handles QUAN -> Fiat toggle', () { + final logic = createLogic(); + final amount = BigInt.from(1500000000000); // 1.5 QUAN + final result = logic.getToggledInput(wasFlipped: false, currentAmount: amount); + + expect(result.text, '1.50'); + expect(result.amount, BigInt.from(1500000000000)); + }); + + test('getToggledInput handles Fiat -> QUAN toggle', () { + final logic = createLogic(); + final amount = BigInt.from(1500000000000); // 1.5 QUAN + final result = logic.getToggledInput(wasFlipped: true, currentAmount: amount); + + expect(result.text, '1.5'); + expect(result.amount, amount); + }); + + test('onAmountChanged handles QUAN input', () { + final logic = createLogic(); + final result = logic.onAmountChanged(value: '1.5', isFlipped: false); + expect(result, BigInt.from(1500000000000)); + }); + + test('onAmountChanged handles Fiat input', () { + final logic = createLogic(); + final result = logic.onAmountChanged(value: '1.50', isFlipped: true); + expect(result, BigInt.from(1500000000000)); + }); + + test('quanToFiatString returns empty string for zero', () { + final logic = createLogic(); + expect(logic.quanToFiatString(BigInt.zero), ''); + }); + + test('formatQuanAmount returns empty string for zero', () { + final logic = createLogic(); + expect(logic.formatQuanAmount(BigInt.zero), ''); + }); + }); +} diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index dd8032f0d..684b5c619 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -11,9 +11,7 @@ class SettingsService { SettingsService._internal(); late SharedPreferences _prefs; - final _secureStorage = const FlutterSecureStorage( - mOptions: MacOsOptions(usesDataProtectionKeychain: false), - ); + final _secureStorage = const FlutterSecureStorage(mOptions: MacOsOptions(usesDataProtectionKeychain: false)); // New keys for multi-account support static const String _accountsKey = 'accounts_v5';