From fbb054f8f63842502ac731f35944bd30308dbfea Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 21:55:29 -0600 Subject: [PATCH] feat: show sat/vB fee rate inline next to fee amount Replaces the separate sats/vByte row on the send confirmation screen with a wrapping amount + rate line that survives long amounts at large text scales. The rate is omitted when the virtual size is unknown or non positive, and uses the same locale decimal separator as the amount it sits beside. closes #413 --- .../send_view/confirm_transaction_view.dart | 92 +++++++------------ .../sub_widgets/fee_amount_with_rate.dart | 70 ++++++++++++++ .../send_view/fee_amount_with_rate_test.dart | 83 +++++++++++++++++ 3 files changed, 185 insertions(+), 60 deletions(-) create mode 100644 lib/pages/send_view/sub_widgets/fee_amount_with_rate.dart create mode 100644 test/pages/send_view/fee_amount_with_rate_test.dart 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); + }); +}