diff --git a/lib/pages/namecoin_names/confirm_name_transaction_view.dart b/lib/pages/namecoin_names/confirm_name_transaction_view.dart index ff2f0b74a4..548b5aeb2a 100644 --- a/lib/pages/namecoin_names/confirm_name_transaction_view.dart +++ b/lib/pages/namecoin_names/confirm_name_transaction_view.dart @@ -49,6 +49,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../pinpad_views/lock_screen_view.dart'; +import '../send_view/sub_widgets/fee_amount_with_rate.dart'; import '../send_view/sub_widgets/sending_transaction_dialog.dart'; class ConfirmNameTransactionView extends ConsumerStatefulWidget { @@ -225,10 +226,9 @@ class _ConfirmNameTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), onPressed: () { @@ -270,84 +270,83 @@ class _ConfirmNameTransactionViewState final fee = widget.txData.fee; final amountWithoutChange = widget.txData.amountWithoutChange!; + final feeRateVSize = widget.txData.fee == null ? null : widget.txData.vSize; + final feeRateLocale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - // if (FocusScope.of(context).hasFocus) { - // FocusScope.of(context).unfocus(); - // await Future.delayed(Duration(milliseconds: 50)); - // } - Navigator.of(context).pop(); - }, - ), - title: Text( - "Confirm transaction", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return Padding( - padding: const EdgeInsets.only( - left: 12, - top: 12, - right: 12, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of(context) + .extension()! + .background, + appBar: AppBar( + backgroundColor: Theme.of(context) + .extension()! + .background, + leading: AppBarBackButton( + onPressed: () async { + // if (FocusScope.of(context).hasFocus) { + // FocusScope.of(context).unfocus(); + // await Future.delayed(Duration(milliseconds: 50)); + // } + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, ), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 24, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(4), - child: child, - ), - ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: child, ), ), - ); - }, - ), - ), + ), + ), + ); + }, ), ), + ), + ), child: ConditionalParent( condition: isDesktop, - builder: - (child) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( children: [ - Row( - children: [ - AppBarBackButton( - size: 40, - iconSize: 24, - onPressed: - () => - Navigator.of(context, rootNavigator: true).pop(), - ), - Text( - "Confirm transaction", - style: STextStyles.desktopH3(context), - ), - ], + AppBarBackButton( + size: 40, + iconSize: 24, + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(), + ), + Text( + "Confirm transaction", + style: STextStyles.desktopH3(context), ), - Flexible(child: SingleChildScrollView(child: child)), ], ), + Flexible(child: SingleChildScrollView(child: child)), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, @@ -426,37 +425,29 @@ class _ConfirmNameTransactionViewState child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - "Transaction fee", - style: STextStyles.smallMed12(context), + Expanded( + child: Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + ), ), - SelectableText( - ref.watch(pAmountFormatter(coin)).format(fee!), - style: STextStyles.itemSubtitle12(context), - textAlign: TextAlign.right, + const SizedBox(width: 12), + Expanded( + child: FeeAmountWithRate( + formattedAmount: ref + .watch(pAmountFormatter(coin)) + .format(fee!), + feeRaw: fee.raw, + vSize: feeRateVSize, + locale: feeRateLocale, + amountStyle: STextStyles.itemSubtitle12(context), + rateStyle: STextStyles.smallMed12(context), + alignment: WrapAlignment.end, + ), ), ], ), ), - if (widget.txData.fee != null && widget.txData.vSize != null) - const SizedBox(height: 12), - if (widget.txData.fee != null && widget.txData.vSize != null) - RoundedWhiteContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "sats/vByte", - style: STextStyles.smallMed12(context), - ), - const SizedBox(height: 4), - SelectableText( - "~${fee.raw.toInt() ~/ widget.txData.vSize!}", - style: STextStyles.itemSubtitle12(context), - ), - ], - ), - ), if (widget.txData.note != null && widget.txData.note!.isNotEmpty) const SizedBox(height: 12), @@ -487,18 +478,18 @@ class _ConfirmNameTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: - Theme.of(context).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -550,24 +541,23 @@ class _ConfirmNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.opNameState!.name, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - ), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), ), ], ), ), Container( height: 1, - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), Padding( padding: const EdgeInsets.all(12), @@ -584,14 +574,14 @@ class _ConfirmNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.opNameState!.value, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - ), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), ), ], ), @@ -609,14 +599,12 @@ class _ConfirmNameTransactionViewState children: [ SelectableText( "Note (optional)", - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) .extension()! .textFieldActiveSearchIconRight, - ), + ), textAlign: TextAlign.left, ), const SizedBox(height: 10), @@ -631,49 +619,48 @@ class _ConfirmNameTransactionViewState enableSuggestions: isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ), + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveText, + height: 1.8, + ), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ), - suffixIcon: - noteController.text.isNotEmpty + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState( - () => noteController.text = "", - ); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState( + () => + noteController.text = "", + ); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 20), @@ -697,10 +684,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: Builder( builder: (context) { final externalCalls = ref.watch( @@ -711,21 +697,17 @@ class _ConfirmNameTransactionViewState String fiatAmount = "N/A"; if (externalCalls) { - final price = - ref - .read(priceAnd24hChangeNotifierProvider) - .getPrice(coin) - ?.value; + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getPrice(coin) + ?.value; if (price != null && price > Decimal.zero) { fiatAmount = (amountWithoutChange.decimal * price) .toAmount(fractionDigits: 2) .fiatString( - locale: - ref - .read( - localeServiceChangeNotifierProvider, - ) - .locale, + locale: ref + .read(localeServiceChangeNotifierProvider) + .locale, ); } } @@ -770,10 +752,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( widget.txData.recipients!.first.address, style: STextStyles.itemSubtitle(context), @@ -797,107 +778,68 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - child: SelectableText( - ref.watch(pAmountFormatter(coin)).format(fee!), - style: STextStyles.itemSubtitle(context), - ), - ), - ), - if (isDesktop && - widget.txData.fee != null && - widget.txData.vSize != null) - Padding( - padding: const EdgeInsets.only(top: 16, left: 32), - child: Text( - "sats/vByte", - style: STextStyles.desktopTextExtraExtraSmall(context), - ), - ), - if (isDesktop && - widget.txData.fee != null && - widget.txData.vSize != null) - Padding( - padding: const EdgeInsets.only(top: 10, left: 32, right: 32), - child: RoundedContainer( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - child: SelectableText( - "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", - style: STextStyles.itemSubtitle(context), + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + child: FeeAmountWithRate( + formattedAmount: ref + .watch(pAmountFormatter(coin)) + .format(fee!), + feeRaw: fee.raw, + vSize: feeRateVSize, + locale: feeRateLocale, + amountStyle: STextStyles.itemSubtitle(context), + rateStyle: STextStyles.desktopTextExtraExtraSmall(context), ), ), ), if (!isDesktop) const Spacer(), SizedBox(height: isDesktop ? 23 : 12), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: RoundedContainer( - padding: - isDesktop - ? const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ) - : const EdgeInsets.all(12), - color: - Theme.of( - context, - ).extension()!.snackBarBackSuccess, + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) + : const EdgeInsets.all(12), + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.titleBold12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), ), SelectableText( ref .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.itemSubtitle12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), textAlign: TextAlign.right, ), ], @@ -906,10 +848,9 @@ class _ConfirmNameTransactionViewState ), SizedBox(height: isDesktop ? 28 : 16), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: PrimaryButton( label: "Send", buttonHeight: isDesktop ? ButtonHeight.l : null, @@ -919,47 +860,44 @@ class _ConfirmNameTransactionViewState if (isDesktop) { unlocked = await showDialog( context: context, - builder: - (context) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [DesktopDialogCloseButton()], - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: DesktopAuthSend(coin: coin), - ), - ], + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], ), - ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: DesktopAuthSend(coin: coin), + ), + ], + ), + ), ); } else { - unlocked = await Navigator.push( + unlocked = await Navigator.push( context, - RouteGenerator.getRoute( + RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - popOnSuccess: true, - routeOnSuccessArguments: true, - routeOnSuccess: "", - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to send transaction", - biometricsAuthenticationTitle: - "Confirm Transaction", - ), + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), settings: const RouteSettings( name: "/confirmsendlockscreen", ), @@ -975,10 +913,9 @@ class _ConfirmNameTransactionViewState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: - Util.isDesktop - ? "Invalid passphrase" - : "Invalid PIN", + message: Util.isDesktop + ? "Invalid passphrase" + : "Invalid PIN", context: context, ), ); diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ba495cdd74..4bed0b0651 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -33,7 +33,6 @@ import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; -import '../../utilities/amount/fee_rate_formatter.dart'; import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; @@ -66,6 +65,7 @@ import '../../wl_gen/interfaces/libepiccash_interface.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; +import 'sub_widgets/fee_amount_with_rate.dart'; import 'sub_widgets/multi_recipient.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; @@ -688,8 +688,10 @@ class _ConfirmTransactionViewState fee = widget.txData.fee; amountWithoutChange = widget.txData.amountWithoutChange!; } - - final feeRate = formatApproximateFeeRate(fee?.raw, widget.txData.vSize); + final feeRateVSize = widget.txData.fee == null ? null : widget.txData.vSize; + final feeRateLocale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); return ConditionalParent( condition: !isDesktop, @@ -865,14 +867,25 @@ class _ConfirmTransactionViewState child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - "Transaction fee", - style: STextStyles.smallMed12(context), + Expanded( + child: Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + ), ), - SelectableText( - ref.watch(pAmountFormatter(coin)).format(fee!), - style: STextStyles.itemSubtitle12(context), - textAlign: TextAlign.right, + const SizedBox(width: 12), + Expanded( + child: FeeAmountWithRate( + formattedAmount: ref + .watch(pAmountFormatter(coin)) + .format(fee!), + feeRaw: fee.raw, + vSize: feeRateVSize, + locale: feeRateLocale, + amountStyle: STextStyles.itemSubtitle12(context), + rateStyle: STextStyles.smallMed12(context), + alignment: WrapAlignment.end, + ), ), ], ), @@ -892,24 +905,6 @@ class _ConfirmTransactionViewState ], ), ), - if (feeRate != null) const SizedBox(height: 12), - if (feeRate != null) - RoundedWhiteContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "sats/vByte", - style: STextStyles.smallMed12(context), - ), - const SizedBox(height: 4), - SelectableText( - feeRate, - style: STextStyles.itemSubtitle12(context), - ), - ], - ), - ), if ((coin is Epiccash || coin is Mimblewimblecoin) && widget.txData.noteOnChain!.isNotEmpty) const SizedBox(height: 12), @@ -1501,34 +1496,15 @@ class _ConfirmTransactionViewState color: Theme.of(context) .extension()! .textFieldDefaultBG, - child: SelectableText( - ref.watch(pAmountFormatter(coin)).format(fee!), - style: STextStyles.itemSubtitle(context), - ), - ), - ), - if (isDesktop && !widget.isPaynymTransaction && feeRate != null) - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "sats/vByte", - style: STextStyles.desktopTextExtraExtraSmall(context), - ), - ), - if (isDesktop && !widget.isPaynymTransaction && feeRate != null) - Padding( - padding: const EdgeInsets.only(top: 10, left: 32, right: 32), - child: RoundedContainer( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ), - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, - child: SelectableText( - feeRate, - style: STextStyles.itemSubtitle(context), + child: FeeAmountWithRate( + formattedAmount: ref + .watch(pAmountFormatter(coin)) + .format(fee!), + feeRaw: fee.raw, + vSize: feeRateVSize, + locale: feeRateLocale, + amountStyle: STextStyles.itemSubtitle(context), + rateStyle: STextStyles.desktopTextExtraExtraSmall(context), ), ), ), diff --git a/lib/pages/send_view/sub_widgets/fee_amount_with_rate.dart b/lib/pages/send_view/sub_widgets/fee_amount_with_rate.dart new file mode 100644 index 0000000000..cdb1fcb9bc --- /dev/null +++ b/lib/pages/send_view/sub_widgets/fee_amount_with_rate.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import '../../../utilities/amount/fee_rate_formatter.dart'; + +/// Fee rate in sat/vB to one decimal, or null when [vSize] is unknown or non +/// positive (fixed fee coins, spark mints, account based coins). +/// +/// [locale] picks the decimal separator so the rate matches the amount +/// formatted next to it. +String? formatFeeRate({ + required BigInt feeRaw, + required int? vSize, + String? locale, +}) { + return formatApproximateFeeRate(feeRaw, vSize, locale: locale); +} + +class FeeAmountWithRate extends StatelessWidget { + const FeeAmountWithRate({ + super.key, + required this.formattedAmount, + required this.feeRaw, + required this.vSize, + required this.amountStyle, + required this.rateStyle, + this.alignment = WrapAlignment.start, + this.locale, + }); + + final String formattedAmount; + final BigInt feeRaw; + final int? vSize; + final TextStyle amountStyle; + final TextStyle rateStyle; + final WrapAlignment alignment; + final String? locale; + + @override + Widget build(BuildContext context) { + final rate = formatFeeRate(feeRaw: feeRaw, vSize: vSize, locale: locale); + + return Wrap( + alignment: alignment, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SelectableText( + formattedAmount, + style: amountStyle, + textAlign: TextAlign.right, + ), + if (rate != null) + Text(' ($rate sat/vB)', style: rateStyle, textAlign: TextAlign.right), + ], + ); + } +} diff --git a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart index 1c466c7ea3..169e709d1a 100644 --- a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart +++ b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart @@ -46,6 +46,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../pinpad_views/lock_screen_view.dart'; +import '../send_view/sub_widgets/fee_amount_with_rate.dart'; import '../send_view/sub_widgets/sending_transaction_dialog.dart'; class ConfirmSparkNameTransactionView extends ConsumerStatefulWidget { @@ -215,9 +216,9 @@ class _ConfirmSparkNameTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), onPressed: () { @@ -259,18 +260,22 @@ class _ConfirmSparkNameTransactionViewState final fee = widget.txData.fee; final amountWithoutChange = widget.txData.amountWithoutChange!; + final feeRateVSize = widget.txData.fee == null ? null : widget.txData.vSize; + final feeRateLocale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); return ConditionalParent( condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -416,37 +421,29 @@ class _ConfirmSparkNameTransactionViewState child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - "Transaction fee", - style: STextStyles.smallMed12(context), + Expanded( + child: Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + ), ), - SelectableText( - ref.watch(pAmountFormatter(coin)).format(fee!), - style: STextStyles.itemSubtitle12(context), - textAlign: TextAlign.right, + const SizedBox(width: 12), + Expanded( + child: FeeAmountWithRate( + formattedAmount: ref + .watch(pAmountFormatter(coin)) + .format(fee!), + feeRaw: fee.raw, + vSize: feeRateVSize, + locale: feeRateLocale, + amountStyle: STextStyles.itemSubtitle12(context), + rateStyle: STextStyles.smallMed12(context), + alignment: WrapAlignment.end, + ), ), ], ), ), - if (widget.txData.fee != null && widget.txData.vSize != null) - const SizedBox(height: 12), - if (widget.txData.fee != null && widget.txData.vSize != null) - RoundedWhiteContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "sats/vByte", - style: STextStyles.smallMed12(context), - ), - const SizedBox(height: 4), - SelectableText( - "~${fee.raw.toInt() ~/ widget.txData.vSize!}", - style: STextStyles.itemSubtitle12(context), - ), - ], - ), - ), if (widget.txData.note != null && widget.txData.note!.isNotEmpty) const SizedBox(height: 12), @@ -477,18 +474,18 @@ class _ConfirmSparkNameTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -544,9 +541,9 @@ class _ConfirmSparkNameTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -554,9 +551,9 @@ class _ConfirmSparkNameTransactionViewState ), Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), Padding( padding: const EdgeInsets.all(12), @@ -577,9 +574,9 @@ class _ConfirmSparkNameTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -620,9 +617,9 @@ class _ConfirmSparkNameTransactionViewState focusNode: _noteFocusNode, style: STextStyles.desktopTextExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, height: 1.8, ), onChanged: (_) => setState(() {}), @@ -683,9 +680,9 @@ class _ConfirmSparkNameTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: Builder( builder: (context) { final externalCalls = ref.watch( @@ -751,9 +748,9 @@ class _ConfirmSparkNameTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( widget.txData.recipients!.first.address, style: STextStyles.itemSubtitle(context), @@ -777,41 +774,18 @@ class _ConfirmSparkNameTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - child: SelectableText( - ref.watch(pAmountFormatter(coin)).format(fee!), - style: STextStyles.itemSubtitle(context), - ), - ), - ), - if (isDesktop && - widget.txData.fee != null && - widget.txData.vSize != null) - Padding( - padding: const EdgeInsets.only(top: 16, left: 32), - child: Text( - "sats/vByte", - style: STextStyles.desktopTextExtraExtraSmall(context), - ), - ), - if (isDesktop && - widget.txData.fee != null && - widget.txData.vSize != null) - Padding( - padding: const EdgeInsets.only(top: 10, left: 32, right: 32), - child: RoundedContainer( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - child: SelectableText( - "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", - style: STextStyles.itemSubtitle(context), + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + child: FeeAmountWithRate( + formattedAmount: ref + .watch(pAmountFormatter(coin)) + .format(fee!), + feeRaw: fee.raw, + vSize: feeRateVSize, + locale: feeRateLocale, + amountStyle: STextStyles.itemSubtitle(context), + rateStyle: STextStyles.desktopTextExtraExtraSmall(context), ), ), ), @@ -825,22 +799,21 @@ class _ConfirmSparkNameTransactionViewState padding: isDesktop ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) : const EdgeInsets.all(12), - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.titleBold12(context).copyWith( color: Theme.of(context) .extension()! @@ -852,13 +825,12 @@ class _ConfirmSparkNameTransactionViewState .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.itemSubtitle12(context).copyWith( color: Theme.of(context) .extension()! @@ -907,9 +879,9 @@ class _ConfirmSparkNameTransactionViewState ), ); } else { - unlocked = await Navigator.push( + unlocked = await Navigator.push( context, - RouteGenerator.getRoute( + RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => const LockscreenView( diff --git a/lib/utilities/amount/fee_rate_formatter.dart b/lib/utilities/amount/fee_rate_formatter.dart index 83e4cb0f24..a7daf41082 100644 --- a/lib/utilities/amount/fee_rate_formatter.dart +++ b/lib/utilities/amount/fee_rate_formatter.dart @@ -1,6 +1,12 @@ +import '../util.dart'; + /// Formats an approximate atomic-unit fee per virtual byte, rounded to tenths. /// Integer arithmetic preserves precision even for fees beyond double's range. -String? formatApproximateFeeRate(BigInt? rawFee, int? virtualSize) { +String? formatApproximateFeeRate( + BigInt? rawFee, + int? virtualSize, { + String? locale, +}) { if (rawFee == null || rawFee.isNegative || virtualSize == null || @@ -11,5 +17,10 @@ String? formatApproximateFeeRate(BigInt? rawFee, int? virtualSize) { final scaled = rawFee * BigInt.from(10); var tenths = scaled ~/ size; if ((scaled % size) * BigInt.two >= size) tenths += BigInt.one; - return '~${tenths ~/ BigInt.from(10)}.${tenths % BigInt.from(10)}'; + final rate = '~${tenths ~/ BigInt.from(10)}.${tenths % BigInt.from(10)}'; + if (locale == null) { + return rate; + } + final separator = Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? '.'; + return separator == '.' ? rate : rate.replaceFirst('.', separator); } diff --git a/test/pages/send_view/fee_amount_with_rate_test.dart b/test/pages/send_view/fee_amount_with_rate_test.dart new file mode 100644 index 0000000000..5c5283568f --- /dev/null +++ b/test/pages/send_view/fee_amount_with_rate_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/fee_amount_with_rate.dart'; + +void main() { + test('formats positive fee rates to one decimal place', () { + expect(formatFeeRate(feeRaw: BigInt.from(126), vSize: 100), '~1.3'); + expect(formatFeeRate(feeRaw: BigInt.from(1000), vSize: 3), '~333.3'); + }); + + test('preserves precision for values beyond double range', () { + final fee = BigInt.parse('900719925474099312345678901234567890'); + expect( + formatFeeRate(feeRaw: fee, vSize: 3), + '~300239975158033104115226300411522630.0', + ); + }); + + test('omits fee rates without a positive virtual size', () { + expect(formatFeeRate(feeRaw: BigInt.from(126), vSize: null), isNull); + expect(formatFeeRate(feeRaw: BigInt.from(126), vSize: 0), isNull); + expect(formatFeeRate(feeRaw: BigInt.from(126), vSize: -1), isNull); + expect(formatFeeRate(feeRaw: BigInt.from(-126), vSize: 100), isNull); + }); + + test('uses the locale decimal separator', () { + expect(formatFeeRate(feeRaw: BigInt.from(126), vSize: 100), '~1.3'); + expect( + formatFeeRate(feeRaw: BigInt.from(126), vSize: 100, locale: 'en_US'), + '~1.3', + ); + expect( + formatFeeRate(feeRaw: BigInt.from(126), vSize: 100, locale: 'de_DE'), + '~1,3', + ); + expect( + formatFeeRate(feeRaw: BigInt.from(126), vSize: 100, locale: 'fr'), + '~1,3', + ); + }); + + testWidgets('renders the rate in the same locale as the amount', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FeeAmountWithRate( + formattedAmount: '0,00000126 BTC', + feeRaw: BigInt.from(126), + vSize: 100, + locale: 'de_DE', + amountStyle: TextStyle(fontSize: 12), + rateStyle: TextStyle(fontSize: 12), + ), + ), + ), + ); + + expect(find.text(' (~1,3 sat/vB)'), findsOneWidget); + }); + + testWidgets('wraps a long amount and rate at large text scale', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData( + size: Size(240, 600), + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: SizedBox( + width: 240, + child: Row( + children: [ + Expanded(child: Text('Transaction fee')), + SizedBox(width: 12), + Expanded( + child: FeeAmountWithRate( + formattedAmount: '1234567890.12345678 BTC', + feeRaw: BigInt.from(999999999), + vSize: 1, + amountStyle: TextStyle(fontSize: 12), + rateStyle: TextStyle(fontSize: 12), + alignment: WrapAlignment.end, + ), + ), + ], + ), + ), + ), + ), + ), + ); + + expect(find.text(' (~999999999.0 sat/vB)'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +}