diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index b54b3b071e..dd4f00cb84 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -67,6 +67,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/mwc_slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; @@ -668,6 +669,10 @@ class _ConfirmTransactionViewState fee = widget.txData.fee; 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, @@ -809,14 +814,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!), + feeSats: fee.raw.toInt(), + vSize: feeRateVSize, + locale: feeRateLocale, + amountStyle: STextStyles.itemSubtitle12(context), + rateStyle: STextStyles.smallMed12(context), + alignment: WrapAlignment.end, + ), ), ], ), @@ -836,25 +852,6 @@ class _ConfirmTransactionViewState ], ), ), - 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 ((coin is Epiccash || coin is Mimblewimblecoin) && widget.txData.noteOnChain!.isNotEmpty) const SizedBox(height: 12), @@ -1388,40 +1385,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 && - widget.txData.fee != null && - widget.txData.vSize != null) - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "sats/vByte", - style: STextStyles.desktopTextExtraExtraSmall(context), - ), - ), - if (isDesktop && - !widget.isPaynymTransaction && - 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), + child: FeeAmountWithRate( + formattedAmount: ref + .watch(pAmountFormatter(coin)) + .format(fee!), + feeSats: fee.raw.toInt(), + 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..861dcd54a3 --- /dev/null +++ b/lib/pages/send_view/sub_widgets/fee_amount_with_rate.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../../utilities/util.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 int feeSats, + required int? vSize, + String? locale, +}) { + if (vSize == null || vSize <= 0) { + return null; + } + + final rate = (feeSats / vSize).toStringAsFixed(1); + if (locale == null) { + return rate; + } + + final separator = Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? '.'; + return separator == '.' ? rate : rate.replaceFirst('.', separator); +} + +class FeeAmountWithRate extends StatelessWidget { + const FeeAmountWithRate({ + super.key, + required this.formattedAmount, + required this.feeSats, + required this.vSize, + required this.amountStyle, + required this.rateStyle, + this.alignment = WrapAlignment.start, + this.locale, + }); + + final String formattedAmount; + final int feeSats; + final int? vSize; + final TextStyle amountStyle; + final TextStyle rateStyle; + final WrapAlignment alignment; + final String? locale; + + @override + Widget build(BuildContext context) { + final rate = formatFeeRate(feeSats: feeSats, 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/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..41b7d67e94 --- /dev/null +++ b/test/pages/send_view/fee_amount_with_rate_test.dart @@ -0,0 +1,83 @@ +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(feeSats: 126, vSize: 100), '1.3'); + expect(formatFeeRate(feeSats: 1000, vSize: 3), '333.3'); + }); + + test('omits fee rates without a positive virtual size', () { + expect(formatFeeRate(feeSats: 126, vSize: null), isNull); + expect(formatFeeRate(feeSats: 126, vSize: 0), isNull); + expect(formatFeeRate(feeSats: 126, vSize: -1), isNull); + }); + + test('uses the locale decimal separator', () { + expect(formatFeeRate(feeSats: 126, vSize: 100), '1.3'); + expect(formatFeeRate(feeSats: 126, vSize: 100, locale: 'en_US'), '1.3'); + expect(formatFeeRate(feeSats: 126, vSize: 100, locale: 'de_DE'), '1,3'); + expect(formatFeeRate(feeSats: 126, vSize: 100, locale: 'fr'), '1,3'); + }); + + testWidgets('renders the rate in the same locale as the amount', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: FeeAmountWithRate( + formattedAmount: '0,00000126 BTC', + feeSats: 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( + const 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', + feeSats: 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); + }); +}