From cb194fbaab6f733d70ce6dda13e39a09b00c628c Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Tue, 28 Jul 2026 00:13:29 +0200 Subject: [PATCH] intoduce-moneytext (#3447) * feat: Introduce `MoneyText` and `CurrencySymbolText` Widgets * feat: add accessibility to `MoneyText` --- cw_core/lib/amount/money.dart | 76 ++-- cw_core/lib/amount/money_double.dart | 6 +- cw_core/lib/amount/money_local.dart | 47 +++ cw_core/test/amount/money_local_test.dart | 128 +++++++ cw_core/test/amount/money_test.dart | 34 +- lib/main.dart | 6 +- .../widgets/money/currency_symbol_text.dart | 165 +++++++++ .../widgets/money/money_settings_cubit.dart | 78 ++++ .../money/money_settings_provider.dart | 15 + lib/new-ui/widgets/money/money_text.dart | 256 +++++++++++++ res/values/strings_de.arb | 1 + res/values/strings_en.arb | 1 + .../money/currency_symbol_text_test.dart | 213 +++++++++++ .../new-ui/widgets/money/money_text_test.dart | 340 ++++++++++++++++++ 14 files changed, 1335 insertions(+), 31 deletions(-) create mode 100644 cw_core/lib/amount/money_local.dart create mode 100644 cw_core/test/amount/money_local_test.dart create mode 100644 lib/new-ui/widgets/money/currency_symbol_text.dart create mode 100644 lib/new-ui/widgets/money/money_settings_cubit.dart create mode 100644 lib/new-ui/widgets/money/money_settings_provider.dart create mode 100644 lib/new-ui/widgets/money/money_text.dart create mode 100644 test/new-ui/widgets/money/currency_symbol_text_test.dart create mode 100644 test/new-ui/widgets/money/money_text_test.dart diff --git a/cw_core/lib/amount/money.dart b/cw_core/lib/amount/money.dart index 9c2332310..20857e092 100644 --- a/cw_core/lib/amount/money.dart +++ b/cw_core/lib/amount/money.dart @@ -1,13 +1,12 @@ -import 'package:cw_core/crypto_amount_format.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/currency.dart'; -import 'package:cw_core/format_fixed.dart'; -import 'package:cw_core/parse_fixed.dart'; +import "package:cw_core/crypto_amount_format.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency.dart"; +import "package:cw_core/format_fixed.dart"; +import "package:cw_core/parse_fixed.dart"; + +export "money_local.dart"; class Money implements Comparable { - final BigInt amount; - final Currency currency; - const Money(this.amount, this.currency); factory Money.zero(Currency currency) => Money(BigInt.zero, currency); @@ -38,6 +37,9 @@ class Money implements Comparable { return amount != null ? Money(amount, currency) : null; } + final BigInt amount; + final Currency currency; + /// Returns the sign of this [BigInt] amount. /// Returns 0 for zero, -1 for values less than zero and +1 for values /// greater than zero. @@ -139,7 +141,9 @@ class Money implements Comparable { /// /// The result is again [Money]. Money operator /(BigInt other) { - if (other == BigInt.zero) throw Exception('Division by zero.'); + if (other == BigInt.zero) { + throw Exception("Division by zero."); + } final neg = (amount.isNegative) ^ (other.isNegative); final A = amount.abs(); @@ -163,7 +167,9 @@ class Money implements Comparable { Money copyWith({BigInt? amount, Currency? currency}) { if (currency != null && amount == null && currency.decimals != this.currency.decimals) { return Money( - _transformAmount(this.amount, this.currency.decimals, currency.decimals), currency); + _transformAmount(this.amount, this.currency.decimals, currency.decimals), + currency, + ); } return Money(amount ?? this.amount, currency ?? this.currency); @@ -173,16 +179,21 @@ class Money implements Comparable { Money _withAmount(BigInt amount) => Money(amount, currency); void _assertSameCurrency(Money other, [String? message]) { - if (currency != other.currency) + if (currency != other.currency) { throw ArgumentError(message ?? "Cannot operate with money values in different currencies."); + } } BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) { - if (sourceDecimals == targetDecimals) return source; + if (sourceDecimals == targetDecimals) { + return source; + } if (sourceDecimals > targetDecimals) { return parseFixed( - formatFixed(source, sourceDecimals).withMaxDecimals(targetDecimals), targetDecimals); + formatFixed(source, sourceDecimals).withMaxDecimals(targetDecimals), + targetDecimals, + ); } else { return parseFixed(formatFixed(source, sourceDecimals), targetDecimals); } @@ -198,18 +209,39 @@ class Money implements Comparable { @override String toString() => formatFixed(amount, currency.decimals); - String toStringWithSymbol( - {int? fractionalDigits, bool trimZeros = true, bool useBaseUnit = false}) => - "${toStringWithPrecision(fractionalDigits: fractionalDigits, trimZeros: trimZeros, useBaseUnit: useBaseUnit)} ${_getSymbol(useBaseUnit)}"; + String toStringWithSymbol({ + int? fractionalDigits, + bool trimZeros = true, + bool useBaseUnit = false, + bool withSymbolPrefix = false, + }) { + final amount = toStringWithPrecision( + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + useBaseUnit: useBaseUnit, + ); + final symbol = getSymbol(useBaseUnit: useBaseUnit); - String toStringWithPrecision( - {int? fractionalDigits, bool trimZeros = true, bool useBaseUnit = false}) => - formatFixed(amount, useBaseUnit ? 0 : currency.decimals, - fractionalDigits: fractionalDigits, trimZeros: trimZeros); + return withSymbolPrefix ? "$symbol $amount" : "$amount $symbol"; + } + + String toStringWithPrecision({ + int? fractionalDigits, + bool trimZeros = true, + bool useBaseUnit = false, + }) => + formatFixed( + amount, + useBaseUnit ? 0 : currency.decimals, + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + ); // To Override the symbol with the ticker of the base unit - String _getSymbol(bool useBaseUnit) { - if (useBaseUnit && [CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) return "sats"; + String getSymbol({required bool useBaseUnit}) { + if (useBaseUnit && [CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) { + return "sats"; + } return currency.symbol; } } diff --git a/cw_core/lib/amount/money_double.dart b/cw_core/lib/amount/money_double.dart index b3331c44b..8dc44c4e3 100644 --- a/cw_core/lib/amount/money_double.dart +++ b/cw_core/lib/amount/money_double.dart @@ -1,6 +1,6 @@ -import 'dart:math'; -import 'package:cw_core/amount/money.dart'; -import 'package:cw_core/currency.dart'; +import "dart:math"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/currency.dart"; extension ToMoney on double { /// Turn a double representation of a currency amount to a proper Money representation diff --git a/cw_core/lib/amount/money_local.dart b/cw_core/lib/amount/money_local.dart new file mode 100644 index 000000000..c5a3c0ea3 --- /dev/null +++ b/cw_core/lib/amount/money_local.dart @@ -0,0 +1,47 @@ +import "package:cw_core/amount/money.dart"; +import "package:intl/intl.dart"; + +extension WithLocalSeparator on Money { + String toLocalStringWithSymbol({ + int? fractionalDigits, + bool trimZeros = true, + bool useBaseUnit = false, + bool withSymbolPrefix = false, + String? locale, + }) { + final amount = toLocalStringWithPrecision( + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + useBaseUnit: useBaseUnit, + locale: locale, + ); + final symbol = getSymbol(useBaseUnit: useBaseUnit); + + return withSymbolPrefix ? "$symbol $amount" : "$amount $symbol"; + } + + String toLocalStringWithPrecision({ + int? fractionalDigits, + bool trimZeros = true, + bool useBaseUnit = false, + String? locale, + }) => + _withLocalSeparator( + toStringWithPrecision( + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + useBaseUnit: useBaseUnit, + ), + locale: locale, + ); + + String _withLocalSeparator(String amount, {String? locale}) { + final isNegative = amount.startsWith("-"); + final formater = NumberFormat("#,###", locale); + final parts = (isNegative ? amount.substring(1) : amount).split("."); + final formatted = [formater.format(int.tryParse(parts.first) ?? 0), ...parts.sublist(1)] + .join(formater.symbols.DECIMAL_SEP); + + return isNegative ? "-$formatted" : formatted; + } +} diff --git a/cw_core/test/amount/money_local_test.dart b/cw_core/test/amount/money_local_test.dart new file mode 100644 index 000000000..24a4fffac --- /dev/null +++ b/cw_core/test/amount/money_local_test.dart @@ -0,0 +1,128 @@ +import "package:cw_core/amount/money.dart"; +import "package:cw_core/amount/money_local.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:intl/intl.dart"; + +final _btc012 = Money.fromInt(12345678, CryptoCurrency.btc); // 0.12345678 BTC +final _btcTrailing = Money.fromInt(12000000, CryptoCurrency.btc); // 0.12 BTC +final _btcGrouping = Money.fromInt(123450000000, CryptoCurrency.btc); // 1234.5 BTC +final _xmrHalf = Money.fromInt(500000000000, CryptoCurrency.xmr); // 0.5 XMR + +void main() { + group("toLocalStringWithPrecision", () { + test("localizes grouping (en_US)", () { + expect(_btcGrouping.toLocalStringWithPrecision(locale: "en_US"), "1,234.5"); + }); + + test("no grouping for sub-1 values", () { + expect(_btc012.toLocalStringWithPrecision(locale: "en_US"), "0.12345678"); + }); + + test("swaps grouping/decimal separators (de_DE)", () { + expect(_btcGrouping.toLocalStringWithPrecision(locale: "de_DE"), "1.234,5"); + }); + + test("base unit renders integer sats with grouping", () { + expect( + _btc012.toLocalStringWithPrecision(useBaseUnit: true, locale: "en_US"), + "12,345,678", + ); + }); + + test("fractionalDigits truncates (does not round)", () { + // 0.12345678 -> 0.12 (NOT 0.13). + expect(_btc012.toLocalStringWithPrecision(fractionalDigits: 2, locale: "en_US"), "0.12"); + }); + + test("trimZeros:false keeps trailing zeros", () { + expect( + _btcTrailing.toLocalStringWithPrecision(trimZeros: false, locale: "en_US"), + "0.12000000", + ); + }); + + test("respects a higher-precision currency (XMR)", () { + expect(_xmrHalf.toLocalStringWithPrecision(locale: "en_US"), "0.5"); + }); + + test("falls back to the ambient Intl locale when locale is null", () { + final previous = Intl.defaultLocale; + addTearDown(() => Intl.defaultLocale = previous); + Intl.defaultLocale = "de_DE"; + + // Proves the extension passes null through to NumberFormat rather than + // hardcoding a locale. + expect(_btcGrouping.toLocalStringWithPrecision(), "1.234,5"); + }); + }); + + group("toLocalStringWithSymbol", () { + test("suffixes the symbol by default", () { + expect(_btcGrouping.toLocalStringWithSymbol(locale: "en_US"), "1,234.5 BTC"); + }); + + test("prefixes the symbol when withSymbolPrefix is true", () { + expect( + _btcGrouping.toLocalStringWithSymbol(withSymbolPrefix: true, locale: "en_US"), + "BTC 1,234.5", + ); + }); + + test("suffixes the base-unit ticker", () { + expect( + _btc012.toLocalStringWithSymbol(useBaseUnit: true, locale: "en_US"), + "12,345,678 sats", + ); + }); + + test("prefixes the base-unit ticker", () { + expect( + _btc012.toLocalStringWithSymbol( + useBaseUnit: true, + withSymbolPrefix: true, + locale: "en_US", + ), + "sats 12,345,678", + ); + }); + + test("prefix respects de_DE separators", () { + expect( + _btcGrouping.toLocalStringWithSymbol(withSymbolPrefix: true, locale: "de_DE"), + "BTC 1.234,5", + ); + }); + + test("uses the currency symbol for non-BTC", () { + expect(_xmrHalf.toLocalStringWithSymbol(locale: "en_US"), "0.5 XMR"); + }); + + test("forwards fractionalDigits to the precision string", () { + expect(_btc012.toLocalStringWithSymbol(fractionalDigits: 2, locale: "en_US"), "0.12 BTC"); + }); + + test("forwards trimZeros to the precision string", () { + expect( + _btcTrailing.toLocalStringWithSymbol(trimZeros: false, locale: "en_US"), + "0.12000000 BTC", + ); + }); + }); + + group("negative amounts", () { + test("keeps the sign when the integer part is non-zero", () { + expect((-_btcGrouping).toLocalStringWithSymbol(locale: "en_US"), "-1,234.5 BTC"); + }); + + // NOTE: currently fails. For |amount| < 1 the sign is dropped: + // formatFixed yields "-0.5", but _withLocalSeparator re-parses the integer + // part and int.tryParse("-0") == 0, so the "-" is lost -> "0.5 XMR". + // Fix in _withLocalSeparator by capturing the sign before parsing, e.g. + // final negative = amount.startsWith("-"); + // ... then re-apply it to the formatted result. + test("keeps the sign for sub-1 amounts", () { + expect((-_xmrHalf).toLocalStringWithSymbol(locale: "en_US"), "-0.5 XMR"); + }); + }); +} diff --git a/cw_core/test/amount/money_test.dart b/cw_core/test/amount/money_test.dart index 45e43e106..8f045bdcb 100644 --- a/cw_core/test/amount/money_test.dart +++ b/cw_core/test/amount/money_test.dart @@ -278,10 +278,14 @@ void main() { }); test("handles exact division perfectly", () { - expect((Money(BigInt.from(100), CryptoCurrency.btc) / BigInt.from(20)).amount, - BigInt.from(5)); - expect((Money(BigInt.from(-100), CryptoCurrency.btc) / BigInt.from(20)).amount, - BigInt.from(-5)); + expect( + (Money(BigInt.from(100), CryptoCurrency.btc) / BigInt.from(20)).amount, + BigInt.from(5), + ); + expect( + (Money(BigInt.from(-100), CryptoCurrency.btc) / BigInt.from(20)).amount, + BigInt.from(-5), + ); }); }); }); @@ -346,6 +350,28 @@ void main() { expect(money.currency.decimals, equals(8)); expect(money.toStringWithSymbol(useBaseUnit: true), "100000000 sats"); }); + + group("withSymbolPrefix", () { + test("with fractionalDigits and padded with zeros", () { + final money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, equals(BigInt.parse("100000000"))); + expect(money.currency.decimals, equals(8)); + expect( + money.toStringWithSymbol(fractionalDigits: 5, trimZeros: false, withSymbolPrefix: true), + "BTC 1.00000", + ); + }); + + test("using base unit sats", () { + final money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, equals(BigInt.parse("100000000"))); + expect(money.currency.decimals, equals(8)); + expect( + money.toStringWithSymbol(useBaseUnit: true, withSymbolPrefix: true), + "sats 100000000", + ); + }); + }); }); }); } diff --git a/lib/main.dart b/lib/main.dart index 923ace77d..068755dcb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'package:cake_wallet/exchange/exchange_template.dart'; import 'package:cake_wallet/exchange/trade_legacy.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/locales/locale.dart'; +import "package:cake_wallet/new-ui/widgets/money/money_settings_provider.dart"; import 'package:cake_wallet/order/order.dart'; import 'package:cake_wallet/reactions/bootstrap.dart'; import 'package:cake_wallet/router.dart' as Router; @@ -425,8 +426,9 @@ class AppState extends State with SingleTickerProviderStateMixin { navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling), - child: child!), + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling), + child: MoneySettingsProvider(settingsStore: settingsStore, child: child!), + ), theme: theme, darkTheme: darkTheme, themeMode: themeMode, diff --git a/lib/new-ui/widgets/money/currency_symbol_text.dart b/lib/new-ui/widgets/money/currency_symbol_text.dart new file mode 100644 index 000000000..82467cce5 --- /dev/null +++ b/lib/new-ui/widgets/money/currency_symbol_text.dart @@ -0,0 +1,165 @@ +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/currency.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; + +/// The [CurrencySymbolText] widget displays [Currency.symbol] based on +/// usersettings as a formated string of text with single style. +class CurrencySymbolText extends StatelessWidget { + const CurrencySymbolText( + this.currency, { + super.key, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.locale, + this.softWrap, + this.overflow, + this.textScaler, + this.maxLines, + this.semanticsLabel, + this.semanticsIdentifier, + this.textWidthBasis, + this.textHeightBehavior, + this.selectionColor, + this.useBaseUnit, + }); + + /// The currency to display. + final Currency currency; + + /// If non-null, the style to use for this text. + /// + /// If the style's "inherit" property is true, the style will be merged with + /// the closest enclosing [DefaultTextStyle]. Otherwise, the style will + /// replace the closest enclosing [DefaultTextStyle]. + /// + /// The user or platform may override this [style]'s [TextStyle.fontWeight], + /// [TextStyle.height], [TextStyle.letterSpacing], and [TextStyle.wordSpacing] + /// via a [MediaQuery] ancestor's [MediaQueryData.boldText], + /// [MediaQueryData.lineHeightScaleFactorOverride], + /// [MediaQueryData.letterSpacingOverride], and [MediaQueryData.wordSpacingOverride] + /// regardless of its [TextStyle.inherit] value. + final TextStyle? style; + + /// The user or platform may override this [strutStyle]'s [StrutStyle.height] + /// via a [MediaQuery] ancestor's [MediaQueryData.lineHeightScaleFactorOverride]. + final StrutStyle? strutStyle; + + /// How the text should be aligned horizontally. + final TextAlign? textAlign; + + /// The directionality of the text. + /// + /// This decides how [textAlign] values like [TextAlign.start] and + /// [TextAlign.end] are interpreted. + /// + /// This is also used to disambiguate how to render bidirectional text. For + /// example, if the [data] is an English phrase followed by a Hebrew phrase, + /// in a [TextDirection.ltr] context the English phrase will be on the left + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] + /// context, the English phrase will be on the right and the Hebrew phrase on + /// its left. + /// + /// Defaults to the ambient [Directionality], if any. + final TextDirection? textDirection; + + /// Used to select a font when the same Unicode character can + /// be rendered differently, depending on the locale. + /// + /// It's rarely necessary to set this property. By default its value + /// is inherited from the enclosing app with `Localizations.localeOf(context)`. + /// + /// See [RenderParagraph.locale] for more information. + final Locale? locale; + + /// Whether the text should break at soft line breaks. + /// + /// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space. + final bool? softWrap; + + /// How visual overflow should be handled. + /// + /// If this is null [TextStyle.overflow] will be used, otherwise the value + /// from the nearest [DefaultTextStyle] ancestor will be used. + final TextOverflow? overflow; + + final TextScaler? textScaler; + + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + /// + /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the + /// edge of the box. + /// + /// If this is null, but there is an ambient [DefaultTextStyle] that specifies + /// an explicit number for its [DefaultTextStyle.maxLines], then the + /// [DefaultTextStyle] value will take precedence. You can use a [RichText] + /// widget directly to entirely override the [DefaultTextStyle]. + final int? maxLines; + + /// An alternative semantics label for this text. + /// + /// If present, the semantics of this widget will contain this value instead + /// of the actual text. This will overwrite any of the semantics labels applied + /// directly to the [TextSpan]s. + /// + /// This is useful for replacing abbreviations or shorthands with the full + /// text value: + /// + /// ```dart + /// const Text(r'$$', semanticsLabel: 'Double dollars') + /// ``` + final String? semanticsLabel; + + /// A unique identifier for the semantics node for this widget. + /// + /// This is useful for cases where the text widget needs to have a uniquely + /// identifiable ID that is recognized through the automation tools without + /// having a dependency on the actual content of the text that can possibly be + /// dynamic in nature. + final String? semanticsIdentifier; + + final TextWidthBasis? textWidthBasis; + + final TextHeightBehavior? textHeightBehavior; + + /// The color to use when painting the selection. + /// + /// This is ignored if [SelectionContainer.maybeOf] returns null + /// in the [BuildContext] of the [Text] widget. + /// + /// If null, the ambient [DefaultSelectionStyle] is used (if any); failing + /// that, the selection color defaults to [DefaultSelectionStyle.defaultColor] + /// (semi-transparent grey). + final Color? selectionColor; + + /// Show the amount in the base unit format of [Money.currency] + /// + /// If null, the displayAmountsInSatoshi setting is used. + final bool? useBaseUnit; + + @override + Widget build(BuildContext context) => BlocBuilder( + builder: (context, state) => Text( + state.getSymbol(currency, overrideSettings: useBaseUnit), + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textDirection: textDirection, + locale: locale, + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + semanticsLabel: semanticsLabel, + semanticsIdentifier: semanticsIdentifier, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + selectionColor: selectionColor, + ), + ); +} diff --git a/lib/new-ui/widgets/money/money_settings_cubit.dart b/lib/new-ui/widgets/money/money_settings_cubit.dart new file mode 100644 index 000000000..0cc03ff33 --- /dev/null +++ b/lib/new-ui/widgets/money/money_settings_cubit.dart @@ -0,0 +1,78 @@ +import "package:bloc/bloc.dart"; +import "package:cake_wallet/entities/balance_display_mode.dart"; +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; +import "package:cake_wallet/src/screens/wallet_connect/utils/string_parsing.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency.dart"; +import "package:mobx/mobx.dart"; + +class MoneySettingsCubit extends Cubit { + MoneySettingsCubit(SettingsStore _settingsStore) + : super( + MoneySettingsState( + bitcoinAmountDisplayMode: _settingsStore.displayAmountsInSatoshi, + displayMode: _settingsStore.balanceDisplayMode, + ), + ) { + _bitcoinAmountDisplayModeDisposer = reaction( + (_) => _settingsStore.displayAmountsInSatoshi, + (displayMode) => emit(state.copyWith(bitcoinAmountDisplayMode: displayMode)), + ); + _displayModeDisposer = reaction( + (_) => _settingsStore.balanceDisplayMode, + (displayMode) => emit(state.copyWith(displayMode: displayMode)), + ); + } + + late final ReactionDisposer _bitcoinAmountDisplayModeDisposer; + + late final ReactionDisposer _displayModeDisposer; + + @override + Future close() { + if (!_bitcoinAmountDisplayModeDisposer.reaction.isDisposed) { + _bitcoinAmountDisplayModeDisposer.reaction.dispose(); + } + if (!_displayModeDisposer.reaction.isDisposed) { + _displayModeDisposer.reaction.dispose(); + } + return super.close(); + } +} + +class MoneySettingsState { + const MoneySettingsState({required this.bitcoinAmountDisplayMode, required this.displayMode}); + + final BitcoinAmountDisplayMode bitcoinAmountDisplayMode; + final BalanceDisplayMode displayMode; + + bool useBaseUnit(Currency currency) => + ([CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency) && + bitcoinAmountDisplayMode == BitcoinAmountDisplayMode.satoshi) || + (CryptoCurrency.btcln == currency && + bitcoinAmountDisplayMode == BitcoinAmountDisplayMode.satoshiForLightning); + + bool get isHidden => displayMode == BalanceDisplayMode.hiddenBalance; + + String getSymbol(Currency currency, {bool? overrideSettings}) { + if (overrideSettings == null) { + return useBaseUnit(currency) ? "sats" : currency.symbol.safeSubString(0, 8); + } + + if (overrideSettings == true && [CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) { + return "sats"; + } + return currency.symbol.safeSubString(0, 8); + } + + + MoneySettingsState copyWith({ + BitcoinAmountDisplayMode? bitcoinAmountDisplayMode, + BalanceDisplayMode? displayMode, + }) => + MoneySettingsState( + bitcoinAmountDisplayMode: bitcoinAmountDisplayMode ?? this.bitcoinAmountDisplayMode, + displayMode: displayMode ?? this.displayMode, + ); +} diff --git a/lib/new-ui/widgets/money/money_settings_provider.dart b/lib/new-ui/widgets/money/money_settings_provider.dart new file mode 100644 index 000000000..17831a28a --- /dev/null +++ b/lib/new-ui/widgets/money/money_settings_provider.dart @@ -0,0 +1,15 @@ +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; + +class MoneySettingsProvider extends StatelessWidget { + const MoneySettingsProvider({required this.settingsStore, this.child, super.key}); + + final Widget? child; + final SettingsStore settingsStore; + + @override + Widget build(BuildContext context) => + BlocProvider(create: (_) => MoneySettingsCubit(settingsStore), child: child); +} diff --git a/lib/new-ui/widgets/money/money_text.dart b/lib/new-ui/widgets/money/money_text.dart new file mode 100644 index 000000000..c96de9498 --- /dev/null +++ b/lib/new-ui/widgets/money/money_text.dart @@ -0,0 +1,256 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cw_core/amount/money.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; + +/// The [MoneyText] widget displays [Money] as a formated string of text with single style. +class MoneyText extends StatelessWidget { + const MoneyText( + this.amount, { + super.key, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.locale, + this.softWrap, + this.overflow, + this.textScaler, + this.maxLines, + this.semanticsLabel, + this.semanticsIdentifier, + this.textWidthBasis, + this.textHeightBehavior, + this.selectionColor, + this.isHiddenAmount, + this.useBaseUnit, + this.fractionalDigits = 8, + this.showSymbol = true, + this.withSymbolPrefix = false, + this.trimZeros = true, + }); + + /// The [MoneyText.optional] returns a widget displaying [Money] as a + /// formated string or an [SizedBox.shrink]. + static Widget optional( + Money? amount, { + Key? key, + TextStyle? style, + StrutStyle? strutStyle, + TextAlign? textAlign, + TextDirection? textDirection, + Locale? locale, + bool? softWrap, + TextOverflow? overflow, + TextScaler? textScaler, + int? maxLines, + String? semanticsLabel, + String? semanticsIdentifier, + TextWidthBasis? textWidthBasis, + TextHeightBehavior? textHeightBehavior, + Color? selectionColor, + bool? isHiddenAmount, + bool? useBaseUnit, + int fractionalDigits = 8, + bool showSymbol = true, + bool withSymbolPrefix = false, + bool trimZeros = true, + }) => + amount != null + ? MoneyText( + amount, + key: key, + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textDirection: textDirection, + locale: locale, + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + semanticsLabel: semanticsLabel, + semanticsIdentifier: semanticsIdentifier, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + selectionColor: selectionColor, + isHiddenAmount: isHiddenAmount, + useBaseUnit: useBaseUnit, + fractionalDigits: fractionalDigits, + showSymbol: showSymbol, + withSymbolPrefix: withSymbolPrefix, + trimZeros: trimZeros, + ) + : const SizedBox.shrink(); + + /// The amount to display. + final Money amount; + + /// If non-null, the style to use for this text. + /// + /// If the style's "inherit" property is true, the style will be merged with + /// the closest enclosing [DefaultTextStyle]. Otherwise, the style will + /// replace the closest enclosing [DefaultTextStyle]. + /// + /// The user or platform may override this [style]'s [TextStyle.fontWeight], + /// [TextStyle.height], [TextStyle.letterSpacing], and [TextStyle.wordSpacing] + /// via a [MediaQuery] ancestor's [MediaQueryData.boldText], + /// [MediaQueryData.lineHeightScaleFactorOverride], + /// [MediaQueryData.letterSpacingOverride], and [MediaQueryData.wordSpacingOverride] + /// regardless of its [TextStyle.inherit] value. + final TextStyle? style; + + /// The user or platform may override this [strutStyle]'s [StrutStyle.height] + /// via a [MediaQuery] ancestor's [MediaQueryData.lineHeightScaleFactorOverride]. + final StrutStyle? strutStyle; + + /// How the text should be aligned horizontally. + final TextAlign? textAlign; + + /// The directionality of the text. + /// + /// This decides how [textAlign] values like [TextAlign.start] and + /// [TextAlign.end] are interpreted. + /// + /// This is also used to disambiguate how to render bidirectional text. For + /// example, if the [data] is an English phrase followed by a Hebrew phrase, + /// in a [TextDirection.ltr] context the English phrase will be on the left + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] + /// context, the English phrase will be on the right and the Hebrew phrase on + /// its left. + /// + /// Defaults to the ambient [Directionality], if any. + final TextDirection? textDirection; + + /// Used to select a font when the same Unicode character can + /// be rendered differently, depending on the locale. + /// + /// It's rarely necessary to set this property. By default its value + /// is inherited from the enclosing app with `Localizations.localeOf(context)`. + /// + /// See [RenderParagraph.locale] for more information. + final Locale? locale; + + /// Whether the text should break at soft line breaks. + /// + /// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space. + final bool? softWrap; + + /// How visual overflow should be handled. + /// + /// If this is null [TextStyle.overflow] will be used, otherwise the value + /// from the nearest [DefaultTextStyle] ancestor will be used. + final TextOverflow? overflow; + + final TextScaler? textScaler; + + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + /// + /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the + /// edge of the box. + /// + /// If this is null, but there is an ambient [DefaultTextStyle] that specifies + /// an explicit number for its [DefaultTextStyle.maxLines], then the + /// [DefaultTextStyle] value will take precedence. You can use a [RichText] + /// widget directly to entirely override the [DefaultTextStyle]. + final int? maxLines; + + /// An alternative semantics label for this text. + /// + /// If present, the semantics of this widget will contain this value instead + /// of the actual text. This will overwrite any of the semantics labels applied + /// directly to the [TextSpan]s. + /// + /// This is useful for replacing abbreviations or shorthands with the full + /// text value: + /// + /// ```dart + /// const Text(r'$$', semanticsLabel: 'Double dollars') + /// ``` + final String? semanticsLabel; + + /// A unique identifier for the semantics node for this widget. + /// + /// This is useful for cases where the text widget needs to have a uniquely + /// identifiable ID that is recognized through the automation tools without + /// having a dependency on the actual content of the text that can possibly be + /// dynamic in nature. + final String? semanticsIdentifier; + + final TextWidthBasis? textWidthBasis; + + final TextHeightBehavior? textHeightBehavior; + + /// The color to use when painting the selection. + /// + /// This is ignored if [SelectionContainer.maybeOf] returns null + /// in the [BuildContext] of the [Text] widget. + /// + /// If null, the ambient [DefaultSelectionStyle] is used (if any); failing + /// that, the selection color defaults to [DefaultSelectionStyle.defaultColor] + /// (semi-transparent grey). + final Color? selectionColor; + + /// Show the amount in the base unit format of [Money.currency] + /// + /// If null, the displayAmountsInSatoshi setting is used. + final bool? useBaseUnit; + + /// A limit for the display of the fractional digits of the amount + final int fractionalDigits; + + /// Hide the amount + /// + /// If null, the value from the balanceDisplayMode setting is used. + final bool? isHiddenAmount; + + /// Show the currency symbol + final bool showSymbol; + + /// Prefix the amount with the currency symbol if [showSymbol] is true + final bool withSymbolPrefix; + + /// Trim the zeros at the end of an amount + final bool trimZeros; + + @override + Widget build(BuildContext context) => BlocBuilder( + builder: (context, state) => Text( + isHiddenAmount ?? state.isHidden + ? "●●●●●●" + : showSymbol + ? amount.toLocalStringWithSymbol( + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + useBaseUnit: useBaseUnit ?? state.useBaseUnit(amount.currency), + withSymbolPrefix: withSymbolPrefix, + locale: (locale ?? Localizations.localeOf(context)).toString(), + ) + : amount.toLocalStringWithPrecision( + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + useBaseUnit: useBaseUnit ?? state.useBaseUnit(amount.currency), + locale: (locale ?? Localizations.localeOf(context)).toString(), + ), + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textDirection: textDirection, + locale: locale, + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + semanticsLabel: isHiddenAmount ?? state.isHidden + ? (semanticsLabel ?? S.of(context).amount_hidden) + : semanticsLabel, + semanticsIdentifier: semanticsIdentifier, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + selectionColor: selectionColor, + ), + ); +} diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 99bbb8b0d..2b8d0b5e7 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -62,6 +62,7 @@ "already_your_username": "Das ist bereits Ihr Benutzername!", "always": "Immer", "amount": "Betrag: ", + "amount_hidden": "Versteckter Betrag", "amount_is_below_minimum_limit": "Ihr Guthaben nach Gebühren wäre geringer als der für den Tausch erforderliche Mindestbetrag (${min})", "amount_is_estimate": "Der Empfangsbetrag ist eine Schätzung", "amount_is_guaranteed": "Der Empfangsbetrag ist garantiert", diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index f1ab85bee..97fac4c54 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -64,6 +64,7 @@ "already_your_username": "This is already your username!", "always": "Always", "amount": "Amount: ", + "amount_hidden": "Amount hidden", "amount_is_below_minimum_limit": "Your balance after fees would be less than the minimum amount needed for the exchange (${min})", "amount_is_estimate": "The receive amount is an estimate", "amount_is_guaranteed": "The receive amount is guaranteed", diff --git a/test/new-ui/widgets/money/currency_symbol_text_test.dart b/test/new-ui/widgets/money/currency_symbol_text_test.dart new file mode 100644 index 000000000..6292f243d --- /dev/null +++ b/test/new-ui/widgets/money/currency_symbol_text_test.dart @@ -0,0 +1,213 @@ +import "package:cake_wallet/entities/balance_display_mode.dart"; +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; +import "package:cake_wallet/new-ui/widgets/money/currency_symbol_text.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:flutter_test/flutter_test.dart"; + +class FakeMoneySettingsCubit extends Cubit implements MoneySettingsCubit { + FakeMoneySettingsCubit(super.initialState); + + void setState(MoneySettingsState state) => emit(state); +} + +const _bitcoinMode = MoneySettingsState( + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.bitcoin, + displayMode: BalanceDisplayMode.fullBalance, +); + +const _satoshiMode = MoneySettingsState( + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.satoshi, + displayMode: BalanceDisplayMode.fullBalance, +); + +const _lnSatoshiMode = MoneySettingsState( + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.satoshiForLightning, + displayMode: BalanceDisplayMode.fullBalance, +); + +/// A non-BTC currency whose symbol exceeds the 8-char cap. `raw: -1` keeps it +/// distinct from every real currency, so it never counts as BTC/BTCLN. +const _longSymbol = CryptoCurrency(title: "SUPERLONGTOKEN", name: "superlongtoken", decimals: 8); + +Future _pump( + WidgetTester tester, + Widget child, + MoneySettingsState state, + ) async { + final cubit = FakeMoneySettingsCubit(state); + addTearDown(cubit.close); + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: cubit, + child: Scaffold(body: Center(child: child)), + ), + ), + ); + return cubit; +} + +void main() { + group("useBaseUnit == null (follows settings)", () { + testWidgets("BTC in satoshi mode -> sats", (tester) async { + await _pump(tester, const CurrencySymbolText(CryptoCurrency.btc), _satoshiMode); + + expect(find.text("sats"), findsOneWidget); + }); + + testWidgets("BTC in bitcoin mode -> BTC", (tester) async { + await _pump(tester, const CurrencySymbolText(CryptoCurrency.btc), _bitcoinMode); + + expect(find.text("BTC"), findsOneWidget); + }); + + testWidgets("non-BTC currency ignores satoshi mode", (tester) async { + await _pump(tester, const CurrencySymbolText(CryptoCurrency.xmr), _satoshiMode); + + expect(find.text("XMR"), findsOneWidget); + }); + + testWidgets("BTCLN in LN-satoshi mode -> sats", (tester) async { + await _pump( + tester, + const CurrencySymbolText(CryptoCurrency.btcln), + _lnSatoshiMode, + ); + + expect(find.text("sats"), findsOneWidget); + }); + + testWidgets("BTCLN in bitcoin mode -> BTC", (tester) async { + await _pump(tester, const CurrencySymbolText(CryptoCurrency.btcln), _bitcoinMode); + + // BTCLN's title/symbol is "BTC". + expect(find.text("BTC"), findsOneWidget); + }); + }); + + group("useBaseUnit == true (force base unit where supported)", () { + testWidgets("BTC -> sats regardless of settings", (tester) async { + await _pump( + tester, + const CurrencySymbolText(CryptoCurrency.btc, useBaseUnit: true), + _bitcoinMode, // settings say bitcoin, but override wins + ); + + expect(find.text("sats"), findsOneWidget); + }); + + testWidgets("BTCLN -> sats", (tester) async { + await _pump( + tester, + const CurrencySymbolText(CryptoCurrency.btcln, useBaseUnit: true), + _bitcoinMode, + ); + + expect(find.text("sats"), findsOneWidget); + }); + + testWidgets("non-BTC currency keeps its symbol even when forced", (tester) async { + await _pump( + tester, + const CurrencySymbolText(CryptoCurrency.xmr, useBaseUnit: true), + _satoshiMode, + ); + + expect(find.text("XMR"), findsOneWidget); + }); + }); + + group("useBaseUnit == false (always the symbol)", () { + testWidgets("BTC -> BTC even in satoshi mode", (tester) async { + await _pump( + tester, + const CurrencySymbolText(CryptoCurrency.btc, useBaseUnit: false), + _satoshiMode, + ); + + expect(find.text("BTC"), findsOneWidget); + expect(find.text("sats"), findsNothing); + }); + + testWidgets("BTCLN -> BTC even in LN-satoshi mode", (tester) async { + await _pump( + tester, + const CurrencySymbolText(CryptoCurrency.btcln, useBaseUnit: false), + _lnSatoshiMode, + ); + + expect(find.text("BTC"), findsOneWidget); + expect(find.text("sats"), findsNothing); + }); + + testWidgets("non-BTC currency -> symbol", (tester) async { + await _pump( + tester, + const CurrencySymbolText(CryptoCurrency.xmr, useBaseUnit: false), + _bitcoinMode, + ); + + expect(find.text("XMR"), findsOneWidget); + }); + }); + + group("symbol formatting", () { + testWidgets("caps symbols longer than 8 characters", (tester) async { + await _pump( + tester, + const CurrencySymbolText(_longSymbol, useBaseUnit: false), + _bitcoinMode, + ); + + // "SUPERLONGTOKEN" -> first 8 chars. + expect(find.text("SUPERLON"), findsOneWidget); + }); + }); + + group("rebuild on cubit state change", () { + testWidgets("re-renders the symbol when settings change", (tester) async { + final cubit = await _pump( + tester, + const CurrencySymbolText(CryptoCurrency.btc), + _bitcoinMode, + ); + expect(find.text("BTC"), findsOneWidget); + + cubit.setState(_satoshiMode); + await tester.pump(); + + expect(find.text("BTC"), findsNothing); + expect(find.text("sats"), findsOneWidget); + }); + }); + + group("forwarding to the inner Text", () { + testWidgets("passes through Text properties incl. semanticsIdentifier", (tester) async { + await _pump( + tester, + const CurrencySymbolText( + CryptoCurrency.btc, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.fade, + style: TextStyle(fontSize: 18), + semanticsIdentifier: "currency-symbol", + ), + _bitcoinMode, + ); + + final text = tester.widget( + find.descendant(of: find.byType(CurrencySymbolText), matching: find.byType(Text)), + ); + + expect(text.maxLines, 2); + expect(text.textAlign, TextAlign.center); + expect(text.overflow, TextOverflow.fade); + expect(text.style?.fontSize, 18); + expect(text.semanticsIdentifier, "currency-symbol"); + }); + }); +} diff --git a/test/new-ui/widgets/money/money_text_test.dart b/test/new-ui/widgets/money/money_text_test.dart new file mode 100644 index 000000000..c4fc26610 --- /dev/null +++ b/test/new-ui/widgets/money/money_text_test.dart @@ -0,0 +1,340 @@ +import "package:cake_wallet/entities/balance_display_mode.dart"; +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_text.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:flutter_test/flutter_test.dart"; + +class FakeMoneySettingsCubit extends Cubit implements MoneySettingsCubit { + FakeMoneySettingsCubit(super.initialState); + + void setState(MoneySettingsState state) => emit(state); +} + +/// Visible, BTC shown as bitcoin (not base unit). +const _visibleBtc = MoneySettingsState( + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.bitcoin, + displayMode: BalanceDisplayMode.fullBalance, +); + +/// Hidden balance. +const _hiddenBtc = MoneySettingsState( + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.bitcoin, + displayMode: BalanceDisplayMode.hiddenBalance, +); + +/// Visible, satoshi mode -> BTC/BTCLN use base unit. +const _sats = MoneySettingsState( + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.satoshi, + displayMode: BalanceDisplayMode.fullBalance, +); + +/// Visible, LN-only satoshi mode -> only BTCLN uses base unit. +const _lnSats = MoneySettingsState( + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.satoshiForLightning, + displayMode: BalanceDisplayMode.fullBalance, +); + +// --- Money fixtures -------------------------------------------------------- +final _btc012 = Money.fromInt(12345678, CryptoCurrency.btc); // 0.12345678 BTC +final _btcTrailing = Money.fromInt(12000000, CryptoCurrency.btc); // 0.12 BTC +final _btcGrouping = Money.fromInt(123450000000, CryptoCurrency.btc); // 1234.5 BTC +final _xmrHalf = Money.fromInt(500000000000, CryptoCurrency.xmr); // 0.5 XMR +final _btclnAmount = Money.fromInt(12345678, CryptoCurrency.btcln); // 0.12345678 BTC (LN) + +const _hidden = "●●●●●●"; + +Future _pump( + WidgetTester tester, + Widget child, + MoneySettingsState state, + ) async { + final cubit = FakeMoneySettingsCubit(state); + addTearDown(cubit.close); + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: cubit, + child: Scaffold(body: Center(child: child)), + ), + ), + ); + return cubit; +} + +void main() { + group("hidden amount", () { + testWidgets("isHiddenAmount:true hides even when state is visible", (tester) async { + await _pump(tester, MoneyText(_btc012, isHiddenAmount: true), _visibleBtc); + + expect(find.text(_hidden), findsOneWidget); + expect(find.text("0.12345678 BTC"), findsNothing); + }); + + testWidgets("isHiddenAmount:false overrides a hidden state", (tester) async { + await _pump(tester, MoneyText(_btc012, isHiddenAmount: false), _hiddenBtc); + + expect(find.text("0.12345678 BTC"), findsOneWidget); + expect(find.text(_hidden), findsNothing); + }); + + testWidgets("isHiddenAmount:null falls back to state.isHidden (hidden)", (tester) async { + await _pump(tester, MoneyText(_btc012), _hiddenBtc); + + expect(find.text(_hidden), findsOneWidget); + }); + + testWidgets("isHiddenAmount:null falls back to state.isHidden (visible)", (tester) async { + await _pump(tester, MoneyText(_btc012), _visibleBtc); + + expect(find.text("0.12345678 BTC"), findsOneWidget); + expect(find.text(_hidden), findsNothing); + }); + }); + + group("symbol vs precision", () { + testWidgets("showSymbol:true appends the currency symbol", (tester) async { + await _pump(tester, MoneyText(_btc012), _visibleBtc); + + expect(find.text("0.12345678 BTC"), findsOneWidget); + }); + + testWidgets("showSymbol:false omits the currency symbol", (tester) async { + await _pump(tester, MoneyText(_btc012, showSymbol: false), _visibleBtc); + + expect(find.text("0.12345678"), findsOneWidget); + expect(find.text("0.12345678 BTC"), findsNothing); + }); + }); + + group("withSymbolPrefix", () { + test("defaults to false on the constructor", () { + expect(MoneyText(_btc012).withSymbolPrefix, false); + }); + + test("defaults to false via optional", () { + expect((MoneyText.optional(_btc012) as MoneyText).withSymbolPrefix, false); + }); + + testWidgets("ignored when showSymbol is false", (tester) async { + await _pump( + tester, + MoneyText(_btc012, showSymbol: false, withSymbolPrefix: true), + _visibleBtc, + ); + + expect(find.text("0.12345678"), findsOneWidget); + expect(find.text("BTC 0.12345678"), findsNothing); + expect(find.text("0.12345678 BTC"), findsNothing); + }); + + testWidgets("prefixes the symbol before the amount", (tester) async { + await _pump(tester, MoneyText(_btc012, withSymbolPrefix: true), _visibleBtc); + + expect(find.text("BTC 0.12345678"), findsOneWidget); + }); + + testWidgets("prefixed amount is still localized (en_US grouping)", (tester) async { + await _pump(tester, MoneyText(_btcGrouping, withSymbolPrefix: true), _visibleBtc); + + expect(find.text("BTC 1,234.5"), findsOneWidget); + }); + + testWidgets("prefixed amount respects an explicit de_DE locale", (tester) async { + await _pump( + tester, + MoneyText(_btcGrouping, withSymbolPrefix: true, locale: const Locale("de", "DE")), + _visibleBtc, + ); + + expect(find.text("BTC 1.234,5"), findsOneWidget); + }); + + testWidgets("prefixes the base-unit ticker (sats)", (tester) async { + await _pump( + tester, + MoneyText(_btc012, withSymbolPrefix: true, useBaseUnit: true), + _visibleBtc, + ); + + expect(find.text("sats 12,345,678"), findsOneWidget); + }); + }); + + group("base unit (useBaseUnit)", () { + testWidgets("explicit useBaseUnit:true renders sats", (tester) async { + await _pump(tester, MoneyText(_btc012, useBaseUnit: true), _visibleBtc); + + // 12345678 sats, localized grouping applied by withLocalSeperator. + expect(find.text("12,345,678 sats"), findsOneWidget); + }); + + testWidgets("explicit useBaseUnit:false overrides a satoshi state", (tester) async { + await _pump(tester, MoneyText(_btc012, useBaseUnit: false), _sats); + + expect(find.text("0.12345678 BTC"), findsOneWidget); + }); + + testWidgets("useBaseUnit:null -> state decides (BTC in satoshi mode -> sats)", (tester) async { + await _pump(tester, MoneyText(_btc012), _sats); + + expect(find.text("12,345,678 sats"), findsOneWidget); + }); + + testWidgets("useBaseUnit:null -> state decides (BTC in bitcoin mode -> BTC)", (tester) async { + await _pump(tester, MoneyText(_btc012), _visibleBtc); + + expect(find.text("0.12345678 BTC"), findsOneWidget); + }); + + testWidgets("non-BTC currency ignores satoshi mode", (tester) async { + await _pump(tester, MoneyText(_xmrHalf), _sats); + + expect(find.text("0.5 XMR"), findsOneWidget); + }); + + testWidgets("LN-satoshi mode makes BTCLN use base unit", (tester) async { + await _pump(tester, MoneyText(_btclnAmount), _lnSats); + + expect(find.text("12,345,678 sats"), findsOneWidget); + }); + + testWidgets("LN-satoshi mode leaves plain BTC untouched", (tester) async { + await _pump(tester, MoneyText(_btc012), _lnSats); + + expect(find.text("0.12345678 BTC"), findsOneWidget); + }); + }); + + group("fractionalDigits & trimZeros", () { + testWidgets("fractionalDigits truncates (does not round)", (tester) async { + await _pump(tester, MoneyText(_btc012, fractionalDigits: 2), _visibleBtc); + + // 0.12345678 truncated to 2 digits -> 0.12 (NOT 0.13). + expect(find.text("0.12 BTC"), findsOneWidget); + }); + + testWidgets("trimZeros:true (default) removes trailing zeros", (tester) async { + await _pump(tester, MoneyText(_btcTrailing), _visibleBtc); + + expect(find.text("0.12 BTC"), findsOneWidget); + }); + + testWidgets("trimZeros:false keeps trailing zeros", (tester) async { + await _pump(tester, MoneyText(_btcTrailing, trimZeros: false), _visibleBtc); + + expect(find.text("0.12000000 BTC"), findsOneWidget); + }); + }); + + group("locale & separators", () { + testWidgets('default locale (en_US) uses "," grouping and "." decimal', (tester) async { + await _pump(tester, MoneyText(_btcGrouping), _visibleBtc); + + expect(find.text("1,234.5 BTC"), findsOneWidget); + }); + + testWidgets("explicit de_DE locale swaps grouping/decimal separators", (tester) async { + await _pump( + tester, + MoneyText(_btcGrouping, locale: const Locale("de", "DE")), + _visibleBtc, + ); + + expect(find.text("1.234,5 BTC"), findsOneWidget); + }); + }); + + group("rebuild on cubit state change", () { + testWidgets("re-renders when the settings state changes", (tester) async { + final cubit = await _pump(tester, MoneyText(_btc012), _visibleBtc); + expect(find.text("0.12345678 BTC"), findsOneWidget); + + cubit.setState(_hiddenBtc); + await tester.pump(); + + expect(find.text("0.12345678 BTC"), findsNothing); + expect(find.text(_hidden), findsOneWidget); + }); + }); + + group("forwarding to the inner Text", () { + testWidgets("passes through Text properties incl. semanticsIdentifier", (tester) async { + await _pump( + tester, + MoneyText( + _btc012, + maxLines: 3, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 42), + semanticsIdentifier: "via-ctor", + ), + _visibleBtc, + ); + + final text = tester.widget( + find.descendant(of: find.byType(MoneyText), matching: find.byType(Text)), + ); + + expect(text.maxLines, 3); + expect(text.textAlign, TextAlign.right); + expect(text.overflow, TextOverflow.ellipsis); + expect(text.style?.fontSize, 42); + expect(text.semanticsIdentifier, "via-ctor"); + }); + }); + + group("MoneyText.optional", () { + test("returns SizedBox.shrink for a null amount", () { + final widget = MoneyText.optional(null); + + expect(widget, isA()); + final box = widget as SizedBox; + expect(box.width, 0.0); + expect(box.height, 0.0); + }); + + test("returns a MoneyText for a non-null amount", () { + expect(MoneyText.optional(_btc012), isA()); + }); + + test("forwards money-specific parameters to MoneyText", () { + final widget = MoneyText.optional( + _btc012, + showSymbol: false, + fractionalDigits: 3, + trimZeros: false, + isHiddenAmount: true, + useBaseUnit: true, + withSymbolPrefix: true, + ) as MoneyText; + + expect(widget.showSymbol, false); + expect(widget.fractionalDigits, 3); + expect(widget.trimZeros, false); + expect(widget.isHiddenAmount, true); + expect(widget.useBaseUnit, true); + expect(widget.withSymbolPrefix, true); + }); + + testWidgets("renders identically to a direct MoneyText", (tester) async { + await _pump(tester, MoneyText.optional(_btc012), _visibleBtc); + + expect(find.byType(MoneyText), findsOneWidget); + expect(find.text("0.12345678 BTC"), findsOneWidget); + }); + + test( + "forwards semanticsIdentifier to MoneyText", + () { + final widget = MoneyText.optional(_btc012, semanticsIdentifier: "sid") as MoneyText; + + expect(widget.semanticsIdentifier, "sid"); + }, + ); + }); +}