intoduce-moneytext (#3447)

* feat: Introduce `MoneyText` and `CurrencySymbolText` Widgets * feat: add accessibility to `MoneyText`

Konstantin Ullrich committed Jul 28, 2026 at 00:13 UTC cb194fbaab6f733d70ce6dda13e39a09b00c628c
14 files changed +1335 -31
cw_core/lib/amount/money.dart
+54 -22
@@ -1,13 +1,12 @@
1 -import 'package:cw_core/crypto_amount_format.dart';
2 -import 'package:cw_core/crypto_currency.dart';
3 -import 'package:cw_core/currency.dart';
4 -import 'package:cw_core/format_fixed.dart';
5 -import 'package:cw_core/parse_fixed.dart';
1 +import "package:cw_core/crypto_amount_format.dart";
2 +import "package:cw_core/crypto_currency.dart";
3 +import "package:cw_core/currency.dart";
4 +import "package:cw_core/format_fixed.dart";
5 +import "package:cw_core/parse_fixed.dart";
6
7 -class Money implements Comparable<Money> {
8 - final BigInt amount;
9 - final Currency currency;
7 +export "money_local.dart";
8
9 +class Money implements Comparable<Money> {
10 const Money(this.amount, this.currency);
11
12 factory Money.zero(Currency currency) => Money(BigInt.zero, currency);
@@ -38,6 +37,9 @@ class Money implements Comparable<Money> {
37 return amount != null ? Money(amount, currency) : null;
38 }
39
40 + final BigInt amount;
41 + final Currency currency;
42 +
43 /// Returns the sign of this [BigInt] amount.
44 /// Returns 0 for zero, -1 for values less than zero and +1 for values
45 /// greater than zero.
@@ -139,7 +141,9 @@ class Money implements Comparable<Money> {
141 ///
142 /// The result is again [Money].
143 Money operator /(BigInt other) {
142 - if (other == BigInt.zero) throw Exception('Division by zero.');
144 + if (other == BigInt.zero) {
145 + throw Exception("Division by zero.");
146 + }
147
148 final neg = (amount.isNegative) ^ (other.isNegative);
149 final A = amount.abs();
@@ -163,7 +167,9 @@ class Money implements Comparable<Money> {
167 Money copyWith({BigInt? amount, Currency? currency}) {
168 if (currency != null && amount == null && currency.decimals != this.currency.decimals) {
169 return Money(
166 - _transformAmount(this.amount, this.currency.decimals, currency.decimals), currency);
170 + _transformAmount(this.amount, this.currency.decimals, currency.decimals),
171 + currency,
172 + );
173 }
174
175 return Money(amount ?? this.amount, currency ?? this.currency);
@@ -173,16 +179,21 @@ class Money implements Comparable<Money> {
179 Money _withAmount(BigInt amount) => Money(amount, currency);
180
181 void _assertSameCurrency(Money other, [String? message]) {
176 - if (currency != other.currency)
182 + if (currency != other.currency) {
183 throw ArgumentError(message ?? "Cannot operate with money values in different currencies.");
184 + }
185 }
186
187 BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) {
181 - if (sourceDecimals == targetDecimals) return source;
188 + if (sourceDecimals == targetDecimals) {
189 + return source;
190 + }
191
192 if (sourceDecimals > targetDecimals) {
193 return parseFixed(
185 - formatFixed(source, sourceDecimals).withMaxDecimals(targetDecimals), targetDecimals);
194 + formatFixed(source, sourceDecimals).withMaxDecimals(targetDecimals),
195 + targetDecimals,
196 + );
197 } else {
198 return parseFixed(formatFixed(source, sourceDecimals), targetDecimals);
199 }
@@ -198,18 +209,39 @@ class Money implements Comparable<Money> {
209 @override
210 String toString() => formatFixed(amount, currency.decimals);
211
201 - String toStringWithSymbol(
202 - {int? fractionalDigits, bool trimZeros = true, bool useBaseUnit = false}) =>
203 - "${toStringWithPrecision(fractionalDigits: fractionalDigits, trimZeros: trimZeros, useBaseUnit: useBaseUnit)} ${_getSymbol(useBaseUnit)}";
212 + String toStringWithSymbol({
213 + int? fractionalDigits,
214 + bool trimZeros = true,
215 + bool useBaseUnit = false,
216 + bool withSymbolPrefix = false,
217 + }) {
218 + final amount = toStringWithPrecision(
219 + fractionalDigits: fractionalDigits,
220 + trimZeros: trimZeros,
221 + useBaseUnit: useBaseUnit,
222 + );
223 + final symbol = getSymbol(useBaseUnit: useBaseUnit);
224 +
225 + return withSymbolPrefix ? "$symbol $amount" : "$amount $symbol";
226 + }
227
205 - String toStringWithPrecision(
206 - {int? fractionalDigits, bool trimZeros = true, bool useBaseUnit = false}) =>
207 - formatFixed(amount, useBaseUnit ? 0 : currency.decimals,
208 - fractionalDigits: fractionalDigits, trimZeros: trimZeros);
228 + String toStringWithPrecision({
229 + int? fractionalDigits,
230 + bool trimZeros = true,
231 + bool useBaseUnit = false,
232 + }) =>
233 + formatFixed(
234 + amount,
235 + useBaseUnit ? 0 : currency.decimals,
236 + fractionalDigits: fractionalDigits,
237 + trimZeros: trimZeros,
238 + );
239
240 // To Override the symbol with the ticker of the base unit
211 - String _getSymbol(bool useBaseUnit) {
212 - if (useBaseUnit && [CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) return "sats";
241 + String getSymbol({required bool useBaseUnit}) {
242 + if (useBaseUnit && [CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) {
243 + return "sats";
244 + }
245 return currency.symbol;
246 }
247 }
cw_core/lib/amount/money_double.dart
+3 -3
@@ -1,6 +1,6 @@
1 -import 'dart:math';
2 -import 'package:cw_core/amount/money.dart';
3 -import 'package:cw_core/currency.dart';
1 +import "dart:math";
2 +import "package:cw_core/amount/money.dart";
3 +import "package:cw_core/currency.dart";
4
5 extension ToMoney on double {
6 /// Turn a double representation of a currency amount to a proper Money representation
cw_core/lib/amount/money_local.dart new
+47
@@ -0,0 +1,47 @@
1 +import "package:cw_core/amount/money.dart";
2 +import "package:intl/intl.dart";
3 +
4 +extension WithLocalSeparator on Money {
5 + String toLocalStringWithSymbol({
6 + int? fractionalDigits,
7 + bool trimZeros = true,
8 + bool useBaseUnit = false,
9 + bool withSymbolPrefix = false,
10 + String? locale,
11 + }) {
12 + final amount = toLocalStringWithPrecision(
13 + fractionalDigits: fractionalDigits,
14 + trimZeros: trimZeros,
15 + useBaseUnit: useBaseUnit,
16 + locale: locale,
17 + );
18 + final symbol = getSymbol(useBaseUnit: useBaseUnit);
19 +
20 + return withSymbolPrefix ? "$symbol $amount" : "$amount $symbol";
21 + }
22 +
23 + String toLocalStringWithPrecision({
24 + int? fractionalDigits,
25 + bool trimZeros = true,
26 + bool useBaseUnit = false,
27 + String? locale,
28 + }) =>
29 + _withLocalSeparator(
30 + toStringWithPrecision(
31 + fractionalDigits: fractionalDigits,
32 + trimZeros: trimZeros,
33 + useBaseUnit: useBaseUnit,
34 + ),
35 + locale: locale,
36 + );
37 +
38 + String _withLocalSeparator(String amount, {String? locale}) {
39 + final isNegative = amount.startsWith("-");
40 + final formater = NumberFormat("#,###", locale);
41 + final parts = (isNegative ? amount.substring(1) : amount).split(".");
42 + final formatted = [formater.format(int.tryParse(parts.first) ?? 0), ...parts.sublist(1)]
43 + .join(formater.symbols.DECIMAL_SEP);
44 +
45 + return isNegative ? "-$formatted" : formatted;
46 + }
47 +}
cw_core/test/amount/money_local_test.dart new
+128
@@ -0,0 +1,128 @@
1 +import "package:cw_core/amount/money.dart";
2 +import "package:cw_core/amount/money_local.dart";
3 +import "package:cw_core/crypto_currency.dart";
4 +import "package:flutter_test/flutter_test.dart";
5 +import "package:intl/intl.dart";
6 +
7 +final _btc012 = Money.fromInt(12345678, CryptoCurrency.btc); // 0.12345678 BTC
8 +final _btcTrailing = Money.fromInt(12000000, CryptoCurrency.btc); // 0.12 BTC
9 +final _btcGrouping = Money.fromInt(123450000000, CryptoCurrency.btc); // 1234.5 BTC
10 +final _xmrHalf = Money.fromInt(500000000000, CryptoCurrency.xmr); // 0.5 XMR
11 +
12 +void main() {
13 + group("toLocalStringWithPrecision", () {
14 + test("localizes grouping (en_US)", () {
15 + expect(_btcGrouping.toLocalStringWithPrecision(locale: "en_US"), "1,234.5");
16 + });
17 +
18 + test("no grouping for sub-1 values", () {
19 + expect(_btc012.toLocalStringWithPrecision(locale: "en_US"), "0.12345678");
20 + });
21 +
22 + test("swaps grouping/decimal separators (de_DE)", () {
23 + expect(_btcGrouping.toLocalStringWithPrecision(locale: "de_DE"), "1.234,5");
24 + });
25 +
26 + test("base unit renders integer sats with grouping", () {
27 + expect(
28 + _btc012.toLocalStringWithPrecision(useBaseUnit: true, locale: "en_US"),
29 + "12,345,678",
30 + );
31 + });
32 +
33 + test("fractionalDigits truncates (does not round)", () {
34 + // 0.12345678 -> 0.12 (NOT 0.13).
35 + expect(_btc012.toLocalStringWithPrecision(fractionalDigits: 2, locale: "en_US"), "0.12");
36 + });
37 +
38 + test("trimZeros:false keeps trailing zeros", () {
39 + expect(
40 + _btcTrailing.toLocalStringWithPrecision(trimZeros: false, locale: "en_US"),
41 + "0.12000000",
42 + );
43 + });
44 +
45 + test("respects a higher-precision currency (XMR)", () {
46 + expect(_xmrHalf.toLocalStringWithPrecision(locale: "en_US"), "0.5");
47 + });
48 +
49 + test("falls back to the ambient Intl locale when locale is null", () {
50 + final previous = Intl.defaultLocale;
51 + addTearDown(() => Intl.defaultLocale = previous);
52 + Intl.defaultLocale = "de_DE";
53 +
54 + // Proves the extension passes null through to NumberFormat rather than
55 + // hardcoding a locale.
56 + expect(_btcGrouping.toLocalStringWithPrecision(), "1.234,5");
57 + });
58 + });
59 +
60 + group("toLocalStringWithSymbol", () {
61 + test("suffixes the symbol by default", () {
62 + expect(_btcGrouping.toLocalStringWithSymbol(locale: "en_US"), "1,234.5 BTC");
63 + });
64 +
65 + test("prefixes the symbol when withSymbolPrefix is true", () {
66 + expect(
67 + _btcGrouping.toLocalStringWithSymbol(withSymbolPrefix: true, locale: "en_US"),
68 + "BTC 1,234.5",
69 + );
70 + });
71 +
72 + test("suffixes the base-unit ticker", () {
73 + expect(
74 + _btc012.toLocalStringWithSymbol(useBaseUnit: true, locale: "en_US"),
75 + "12,345,678 sats",
76 + );
77 + });
78 +
79 + test("prefixes the base-unit ticker", () {
80 + expect(
81 + _btc012.toLocalStringWithSymbol(
82 + useBaseUnit: true,
83 + withSymbolPrefix: true,
84 + locale: "en_US",
85 + ),
86 + "sats 12,345,678",
87 + );
88 + });
89 +
90 + test("prefix respects de_DE separators", () {
91 + expect(
92 + _btcGrouping.toLocalStringWithSymbol(withSymbolPrefix: true, locale: "de_DE"),
93 + "BTC 1.234,5",
94 + );
95 + });
96 +
97 + test("uses the currency symbol for non-BTC", () {
98 + expect(_xmrHalf.toLocalStringWithSymbol(locale: "en_US"), "0.5 XMR");
99 + });
100 +
101 + test("forwards fractionalDigits to the precision string", () {
102 + expect(_btc012.toLocalStringWithSymbol(fractionalDigits: 2, locale: "en_US"), "0.12 BTC");
103 + });
104 +
105 + test("forwards trimZeros to the precision string", () {
106 + expect(
107 + _btcTrailing.toLocalStringWithSymbol(trimZeros: false, locale: "en_US"),
108 + "0.12000000 BTC",
109 + );
110 + });
111 + });
112 +
113 + group("negative amounts", () {
114 + test("keeps the sign when the integer part is non-zero", () {
115 + expect((-_btcGrouping).toLocalStringWithSymbol(locale: "en_US"), "-1,234.5 BTC");
116 + });
117 +
118 + // NOTE: currently fails. For |amount| < 1 the sign is dropped:
119 + // formatFixed yields "-0.5", but _withLocalSeparator re-parses the integer
120 + // part and int.tryParse("-0") == 0, so the "-" is lost -> "0.5 XMR".
121 + // Fix in _withLocalSeparator by capturing the sign before parsing, e.g.
122 + // final negative = amount.startsWith("-");
123 + // ... then re-apply it to the formatted result.
124 + test("keeps the sign for sub-1 amounts", () {
125 + expect((-_xmrHalf).toLocalStringWithSymbol(locale: "en_US"), "-0.5 XMR");
126 + });
127 + });
128 +}
cw_core/test/amount/money_test.dart
+30 -4
@@ -278,10 +278,14 @@ void main() {
278 });
279
280 test("handles exact division perfectly", () {
281 - expect((Money(BigInt.from(100), CryptoCurrency.btc) / BigInt.from(20)).amount,
282 - BigInt.from(5));
283 - expect((Money(BigInt.from(-100), CryptoCurrency.btc) / BigInt.from(20)).amount,
284 - BigInt.from(-5));
281 + expect(
282 + (Money(BigInt.from(100), CryptoCurrency.btc) / BigInt.from(20)).amount,
283 + BigInt.from(5),
284 + );
285 + expect(
286 + (Money(BigInt.from(-100), CryptoCurrency.btc) / BigInt.from(20)).amount,
287 + BigInt.from(-5),
288 + );
289 });
290 });
291 });
@@ -346,6 +350,28 @@ void main() {
350 expect(money.currency.decimals, equals(8));
351 expect(money.toStringWithSymbol(useBaseUnit: true), "100000000 sats");
352 });
353 +
354 + group("withSymbolPrefix", () {
355 + test("with fractionalDigits and padded with zeros", () {
356 + final money = Money.parse("1", CryptoCurrency.btc);
357 + expect(money.amount, equals(BigInt.parse("100000000")));
358 + expect(money.currency.decimals, equals(8));
359 + expect(
360 + money.toStringWithSymbol(fractionalDigits: 5, trimZeros: false, withSymbolPrefix: true),
361 + "BTC 1.00000",
362 + );
363 + });
364 +
365 + test("using base unit sats", () {
366 + final money = Money.parse("1", CryptoCurrency.btc);
367 + expect(money.amount, equals(BigInt.parse("100000000")));
368 + expect(money.currency.decimals, equals(8));
369 + expect(
370 + money.toStringWithSymbol(useBaseUnit: true, withSymbolPrefix: true),
371 + "sats 100000000",
372 + );
373 + });
374 + });
375 });
376 });
377 }
lib/main.dart
+4 -2
@@ -23,6 +23,7 @@ import 'package:cake_wallet/exchange/exchange_template.dart';
23 import 'package:cake_wallet/exchange/trade_legacy.dart';
24 import 'package:cake_wallet/generated/i18n.dart';
25 import 'package:cake_wallet/locales/locale.dart';
26 +import "package:cake_wallet/new-ui/widgets/money/money_settings_provider.dart";
27 import 'package:cake_wallet/order/order.dart';
28 import 'package:cake_wallet/reactions/bootstrap.dart';
29 import 'package:cake_wallet/router.dart' as Router;
@@ -425,8 +426,9 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
426 navigatorKey: navigatorKey,
427 debugShowCheckedModeBanner: false,
428 builder: (context, child) => MediaQuery(
428 - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),
429 - child: child!),
429 + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),
430 + child: MoneySettingsProvider(settingsStore: settingsStore, child: child!),
431 + ),
432 theme: theme,
433 darkTheme: darkTheme,
434 themeMode: themeMode,
lib/new-ui/widgets/money/currency_symbol_text.dart new
+165
@@ -0,0 +1,165 @@
1 +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart";
2 +import "package:cw_core/amount/money.dart";
3 +import "package:cw_core/currency.dart";
4 +import "package:flutter/widgets.dart";
5 +import "package:flutter_bloc/flutter_bloc.dart";
6 +
7 +/// The [CurrencySymbolText] widget displays [Currency.symbol] based on
8 +/// usersettings as a formated string of text with single style.
9 +class CurrencySymbolText extends StatelessWidget {
10 + const CurrencySymbolText(
11 + this.currency, {
12 + super.key,
13 + this.style,
14 + this.strutStyle,
15 + this.textAlign,
16 + this.textDirection,
17 + this.locale,
18 + this.softWrap,
19 + this.overflow,
20 + this.textScaler,
21 + this.maxLines,
22 + this.semanticsLabel,
23 + this.semanticsIdentifier,
24 + this.textWidthBasis,
25 + this.textHeightBehavior,
26 + this.selectionColor,
27 + this.useBaseUnit,
28 + });
29 +
30 + /// The currency to display.
31 + final Currency currency;
32 +
33 + /// If non-null, the style to use for this text.
34 + ///
35 + /// If the style's "inherit" property is true, the style will be merged with
36 + /// the closest enclosing [DefaultTextStyle]. Otherwise, the style will
37 + /// replace the closest enclosing [DefaultTextStyle].
38 + ///
39 + /// The user or platform may override this [style]'s [TextStyle.fontWeight],
40 + /// [TextStyle.height], [TextStyle.letterSpacing], and [TextStyle.wordSpacing]
41 + /// via a [MediaQuery] ancestor's [MediaQueryData.boldText],
42 + /// [MediaQueryData.lineHeightScaleFactorOverride],
43 + /// [MediaQueryData.letterSpacingOverride], and [MediaQueryData.wordSpacingOverride]
44 + /// regardless of its [TextStyle.inherit] value.
45 + final TextStyle? style;
46 +
47 + /// The user or platform may override this [strutStyle]'s [StrutStyle.height]
48 + /// via a [MediaQuery] ancestor's [MediaQueryData.lineHeightScaleFactorOverride].
49 + final StrutStyle? strutStyle;
50 +
51 + /// How the text should be aligned horizontally.
52 + final TextAlign? textAlign;
53 +
54 + /// The directionality of the text.
55 + ///
56 + /// This decides how [textAlign] values like [TextAlign.start] and
57 + /// [TextAlign.end] are interpreted.
58 + ///
59 + /// This is also used to disambiguate how to render bidirectional text. For
60 + /// example, if the [data] is an English phrase followed by a Hebrew phrase,
61 + /// in a [TextDirection.ltr] context the English phrase will be on the left
62 + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
63 + /// context, the English phrase will be on the right and the Hebrew phrase on
64 + /// its left.
65 + ///
66 + /// Defaults to the ambient [Directionality], if any.
67 + final TextDirection? textDirection;
68 +
69 + /// Used to select a font when the same Unicode character can
70 + /// be rendered differently, depending on the locale.
71 + ///
72 + /// It's rarely necessary to set this property. By default its value
73 + /// is inherited from the enclosing app with `Localizations.localeOf(context)`.
74 + ///
75 + /// See [RenderParagraph.locale] for more information.
76 + final Locale? locale;
77 +
78 + /// Whether the text should break at soft line breaks.
79 + ///
80 + /// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space.
81 + final bool? softWrap;
82 +
83 + /// How visual overflow should be handled.
84 + ///
85 + /// If this is null [TextStyle.overflow] will be used, otherwise the value
86 + /// from the nearest [DefaultTextStyle] ancestor will be used.
87 + final TextOverflow? overflow;
88 +
89 + final TextScaler? textScaler;
90 +
91 + /// An optional maximum number of lines for the text to span, wrapping if necessary.
92 + /// If the text exceeds the given number of lines, it will be truncated according
93 + /// to [overflow].
94 + ///
95 + /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the
96 + /// edge of the box.
97 + ///
98 + /// If this is null, but there is an ambient [DefaultTextStyle] that specifies
99 + /// an explicit number for its [DefaultTextStyle.maxLines], then the
100 + /// [DefaultTextStyle] value will take precedence. You can use a [RichText]
101 + /// widget directly to entirely override the [DefaultTextStyle].
102 + final int? maxLines;
103 +
104 + /// An alternative semantics label for this text.
105 + ///
106 + /// If present, the semantics of this widget will contain this value instead
107 + /// of the actual text. This will overwrite any of the semantics labels applied
108 + /// directly to the [TextSpan]s.
109 + ///
110 + /// This is useful for replacing abbreviations or shorthands with the full
111 + /// text value:
112 + ///
113 + /// ```dart
114 + /// const Text(r'$$', semanticsLabel: 'Double dollars')
115 + /// ```
116 + final String? semanticsLabel;
117 +
118 + /// A unique identifier for the semantics node for this widget.
119 + ///
120 + /// This is useful for cases where the text widget needs to have a uniquely
121 + /// identifiable ID that is recognized through the automation tools without
122 + /// having a dependency on the actual content of the text that can possibly be
123 + /// dynamic in nature.
124 + final String? semanticsIdentifier;
125 +
126 + final TextWidthBasis? textWidthBasis;
127 +
128 + final TextHeightBehavior? textHeightBehavior;
129 +
130 + /// The color to use when painting the selection.
131 + ///
132 + /// This is ignored if [SelectionContainer.maybeOf] returns null
133 + /// in the [BuildContext] of the [Text] widget.
134 + ///
135 + /// If null, the ambient [DefaultSelectionStyle] is used (if any); failing
136 + /// that, the selection color defaults to [DefaultSelectionStyle.defaultColor]
137 + /// (semi-transparent grey).
138 + final Color? selectionColor;
139 +
140 + /// Show the amount in the base unit format of [Money.currency]
141 + ///
142 + /// If null, the displayAmountsInSatoshi setting is used.
143 + final bool? useBaseUnit;
144 +
145 + @override
146 + Widget build(BuildContext context) => BlocBuilder<MoneySettingsCubit, MoneySettingsState>(
147 + builder: (context, state) => Text(
148 + state.getSymbol(currency, overrideSettings: useBaseUnit),
149 + style: style,
150 + strutStyle: strutStyle,
151 + textAlign: textAlign,
152 + textDirection: textDirection,
153 + locale: locale,
154 + softWrap: softWrap,
155 + overflow: overflow,
156 + textScaler: textScaler,
157 + maxLines: maxLines,
158 + semanticsLabel: semanticsLabel,
159 + semanticsIdentifier: semanticsIdentifier,
160 + textWidthBasis: textWidthBasis,
161 + textHeightBehavior: textHeightBehavior,
162 + selectionColor: selectionColor,
163 + ),
164 + );
165 +}
lib/new-ui/widgets/money/money_settings_cubit.dart new
+78
@@ -0,0 +1,78 @@
1 +import "package:bloc/bloc.dart";
2 +import "package:cake_wallet/entities/balance_display_mode.dart";
3 +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart";
4 +import "package:cake_wallet/src/screens/wallet_connect/utils/string_parsing.dart";
5 +import "package:cake_wallet/store/settings_store.dart";
6 +import "package:cw_core/crypto_currency.dart";
7 +import "package:cw_core/currency.dart";
8 +import "package:mobx/mobx.dart";
9 +
10 +class MoneySettingsCubit extends Cubit<MoneySettingsState> {
11 + MoneySettingsCubit(SettingsStore _settingsStore)
12 + : super(
13 + MoneySettingsState(
14 + bitcoinAmountDisplayMode: _settingsStore.displayAmountsInSatoshi,
15 + displayMode: _settingsStore.balanceDisplayMode,
16 + ),
17 + ) {
18 + _bitcoinAmountDisplayModeDisposer = reaction(
19 + (_) => _settingsStore.displayAmountsInSatoshi,
20 + (displayMode) => emit(state.copyWith(bitcoinAmountDisplayMode: displayMode)),
21 + );
22 + _displayModeDisposer = reaction(
23 + (_) => _settingsStore.balanceDisplayMode,
24 + (displayMode) => emit(state.copyWith(displayMode: displayMode)),
25 + );
26 + }
27 +
28 + late final ReactionDisposer _bitcoinAmountDisplayModeDisposer;
29 +
30 + late final ReactionDisposer _displayModeDisposer;
31 +
32 + @override
33 + Future<void> close() {
34 + if (!_bitcoinAmountDisplayModeDisposer.reaction.isDisposed) {
35 + _bitcoinAmountDisplayModeDisposer.reaction.dispose();
36 + }
37 + if (!_displayModeDisposer.reaction.isDisposed) {
38 + _displayModeDisposer.reaction.dispose();
39 + }
40 + return super.close();
41 + }
42 +}
43 +
44 +class MoneySettingsState {
45 + const MoneySettingsState({required this.bitcoinAmountDisplayMode, required this.displayMode});
46 +
47 + final BitcoinAmountDisplayMode bitcoinAmountDisplayMode;
48 + final BalanceDisplayMode displayMode;
49 +
50 + bool useBaseUnit(Currency currency) =>
51 + ([CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency) &&
52 + bitcoinAmountDisplayMode == BitcoinAmountDisplayMode.satoshi) ||
53 + (CryptoCurrency.btcln == currency &&
54 + bitcoinAmountDisplayMode == BitcoinAmountDisplayMode.satoshiForLightning);
55 +
56 + bool get isHidden => displayMode == BalanceDisplayMode.hiddenBalance;
57 +
58 + String getSymbol(Currency currency, {bool? overrideSettings}) {
59 + if (overrideSettings == null) {
60 + return useBaseUnit(currency) ? "sats" : currency.symbol.safeSubString(0, 8);
61 + }
62 +
63 + if (overrideSettings == true && [CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) {
64 + return "sats";
65 + }
66 + return currency.symbol.safeSubString(0, 8);
67 + }
68 +
69 +
70 + MoneySettingsState copyWith({
71 + BitcoinAmountDisplayMode? bitcoinAmountDisplayMode,
72 + BalanceDisplayMode? displayMode,
73 + }) =>
74 + MoneySettingsState(
75 + bitcoinAmountDisplayMode: bitcoinAmountDisplayMode ?? this.bitcoinAmountDisplayMode,
76 + displayMode: displayMode ?? this.displayMode,
77 + );
78 +}
lib/new-ui/widgets/money/money_settings_provider.dart new
+15
@@ -0,0 +1,15 @@
1 +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart";
2 +import "package:cake_wallet/store/settings_store.dart";
3 +import "package:flutter/material.dart";
4 +import "package:flutter_bloc/flutter_bloc.dart";
5 +
6 +class MoneySettingsProvider extends StatelessWidget {
7 + const MoneySettingsProvider({required this.settingsStore, this.child, super.key});
8 +
9 + final Widget? child;
10 + final SettingsStore settingsStore;
11 +
12 + @override
13 + Widget build(BuildContext context) =>
14 + BlocProvider(create: (_) => MoneySettingsCubit(settingsStore), child: child);
15 +}
lib/new-ui/widgets/money/money_text.dart new
+256
@@ -0,0 +1,256 @@
1 +import "package:cake_wallet/generated/i18n.dart";
2 +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart";
3 +import "package:cw_core/amount/money.dart";
4 +import "package:flutter/widgets.dart";
5 +import "package:flutter_bloc/flutter_bloc.dart";
6 +
7 +/// The [MoneyText] widget displays [Money] as a formated string of text with single style.
8 +class MoneyText extends StatelessWidget {
9 + const MoneyText(
10 + this.amount, {
11 + super.key,
12 + this.style,
13 + this.strutStyle,
14 + this.textAlign,
15 + this.textDirection,
16 + this.locale,
17 + this.softWrap,
18 + this.overflow,
19 + this.textScaler,
20 + this.maxLines,
21 + this.semanticsLabel,
22 + this.semanticsIdentifier,
23 + this.textWidthBasis,
24 + this.textHeightBehavior,
25 + this.selectionColor,
26 + this.isHiddenAmount,
27 + this.useBaseUnit,
28 + this.fractionalDigits = 8,
29 + this.showSymbol = true,
30 + this.withSymbolPrefix = false,
31 + this.trimZeros = true,
32 + });
33 +
34 + /// The [MoneyText.optional] returns a widget displaying [Money] as a
35 + /// formated string or an [SizedBox.shrink].
36 + static Widget optional(
37 + Money? amount, {
38 + Key? key,
39 + TextStyle? style,
40 + StrutStyle? strutStyle,
41 + TextAlign? textAlign,
42 + TextDirection? textDirection,
43 + Locale? locale,
44 + bool? softWrap,
45 + TextOverflow? overflow,
46 + TextScaler? textScaler,
47 + int? maxLines,
48 + String? semanticsLabel,
49 + String? semanticsIdentifier,
50 + TextWidthBasis? textWidthBasis,
51 + TextHeightBehavior? textHeightBehavior,
52 + Color? selectionColor,
53 + bool? isHiddenAmount,
54 + bool? useBaseUnit,
55 + int fractionalDigits = 8,
56 + bool showSymbol = true,
57 + bool withSymbolPrefix = false,
58 + bool trimZeros = true,
59 + }) =>
60 + amount != null
61 + ? MoneyText(
62 + amount,
63 + key: key,
64 + style: style,
65 + strutStyle: strutStyle,
66 + textAlign: textAlign,
67 + textDirection: textDirection,
68 + locale: locale,
69 + softWrap: softWrap,
70 + overflow: overflow,
71 + textScaler: textScaler,
72 + maxLines: maxLines,
73 + semanticsLabel: semanticsLabel,
74 + semanticsIdentifier: semanticsIdentifier,
75 + textWidthBasis: textWidthBasis,
76 + textHeightBehavior: textHeightBehavior,
77 + selectionColor: selectionColor,
78 + isHiddenAmount: isHiddenAmount,
79 + useBaseUnit: useBaseUnit,
80 + fractionalDigits: fractionalDigits,
81 + showSymbol: showSymbol,
82 + withSymbolPrefix: withSymbolPrefix,
83 + trimZeros: trimZeros,
84 + )
85 + : const SizedBox.shrink();
86 +
87 + /// The amount to display.
88 + final Money amount;
89 +
90 + /// If non-null, the style to use for this text.
91 + ///
92 + /// If the style's "inherit" property is true, the style will be merged with
93 + /// the closest enclosing [DefaultTextStyle]. Otherwise, the style will
94 + /// replace the closest enclosing [DefaultTextStyle].
95 + ///
96 + /// The user or platform may override this [style]'s [TextStyle.fontWeight],
97 + /// [TextStyle.height], [TextStyle.letterSpacing], and [TextStyle.wordSpacing]
98 + /// via a [MediaQuery] ancestor's [MediaQueryData.boldText],
99 + /// [MediaQueryData.lineHeightScaleFactorOverride],
100 + /// [MediaQueryData.letterSpacingOverride], and [MediaQueryData.wordSpacingOverride]
101 + /// regardless of its [TextStyle.inherit] value.
102 + final TextStyle? style;
103 +
104 + /// The user or platform may override this [strutStyle]'s [StrutStyle.height]
105 + /// via a [MediaQuery] ancestor's [MediaQueryData.lineHeightScaleFactorOverride].
106 + final StrutStyle? strutStyle;
107 +
108 + /// How the text should be aligned horizontally.
109 + final TextAlign? textAlign;
110 +
111 + /// The directionality of the text.
112 + ///
113 + /// This decides how [textAlign] values like [TextAlign.start] and
114 + /// [TextAlign.end] are interpreted.
115 + ///
116 + /// This is also used to disambiguate how to render bidirectional text. For
117 + /// example, if the [data] is an English phrase followed by a Hebrew phrase,
118 + /// in a [TextDirection.ltr] context the English phrase will be on the left
119 + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
120 + /// context, the English phrase will be on the right and the Hebrew phrase on
121 + /// its left.
122 + ///
123 + /// Defaults to the ambient [Directionality], if any.
124 + final TextDirection? textDirection;
125 +
126 + /// Used to select a font when the same Unicode character can
127 + /// be rendered differently, depending on the locale.
128 + ///
129 + /// It's rarely necessary to set this property. By default its value
130 + /// is inherited from the enclosing app with `Localizations.localeOf(context)`.
131 + ///
132 + /// See [RenderParagraph.locale] for more information.
133 + final Locale? locale;
134 +
135 + /// Whether the text should break at soft line breaks.
136 + ///
137 + /// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space.
138 + final bool? softWrap;
139 +
140 + /// How visual overflow should be handled.
141 + ///
142 + /// If this is null [TextStyle.overflow] will be used, otherwise the value
143 + /// from the nearest [DefaultTextStyle] ancestor will be used.
144 + final TextOverflow? overflow;
145 +
146 + final TextScaler? textScaler;
147 +
148 + /// An optional maximum number of lines for the text to span, wrapping if necessary.
149 + /// If the text exceeds the given number of lines, it will be truncated according
150 + /// to [overflow].
151 + ///
152 + /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the
153 + /// edge of the box.
154 + ///
155 + /// If this is null, but there is an ambient [DefaultTextStyle] that specifies
156 + /// an explicit number for its [DefaultTextStyle.maxLines], then the
157 + /// [DefaultTextStyle] value will take precedence. You can use a [RichText]
158 + /// widget directly to entirely override the [DefaultTextStyle].
159 + final int? maxLines;
160 +
161 + /// An alternative semantics label for this text.
162 + ///
163 + /// If present, the semantics of this widget will contain this value instead
164 + /// of the actual text. This will overwrite any of the semantics labels applied
165 + /// directly to the [TextSpan]s.
166 + ///
167 + /// This is useful for replacing abbreviations or shorthands with the full
168 + /// text value:
169 + ///
170 + /// ```dart
171 + /// const Text(r'$$', semanticsLabel: 'Double dollars')
172 + /// ```
173 + final String? semanticsLabel;
174 +
175 + /// A unique identifier for the semantics node for this widget.
176 + ///
177 + /// This is useful for cases where the text widget needs to have a uniquely
178 + /// identifiable ID that is recognized through the automation tools without
179 + /// having a dependency on the actual content of the text that can possibly be
180 + /// dynamic in nature.
181 + final String? semanticsIdentifier;
182 +
183 + final TextWidthBasis? textWidthBasis;
184 +
185 + final TextHeightBehavior? textHeightBehavior;
186 +
187 + /// The color to use when painting the selection.
188 + ///
189 + /// This is ignored if [SelectionContainer.maybeOf] returns null
190 + /// in the [BuildContext] of the [Text] widget.
191 + ///
192 + /// If null, the ambient [DefaultSelectionStyle] is used (if any); failing
193 + /// that, the selection color defaults to [DefaultSelectionStyle.defaultColor]
194 + /// (semi-transparent grey).
195 + final Color? selectionColor;
196 +
197 + /// Show the amount in the base unit format of [Money.currency]
198 + ///
199 + /// If null, the displayAmountsInSatoshi setting is used.
200 + final bool? useBaseUnit;
201 +
202 + /// A limit for the display of the fractional digits of the amount
203 + final int fractionalDigits;
204 +
205 + /// Hide the amount
206 + ///
207 + /// If null, the value from the balanceDisplayMode setting is used.
208 + final bool? isHiddenAmount;
209 +
210 + /// Show the currency symbol
211 + final bool showSymbol;
212 +
213 + /// Prefix the amount with the currency symbol if [showSymbol] is true
214 + final bool withSymbolPrefix;
215 +
216 + /// Trim the zeros at the end of an amount
217 + final bool trimZeros;
218 +
219 + @override
220 + Widget build(BuildContext context) => BlocBuilder<MoneySettingsCubit, MoneySettingsState>(
221 + builder: (context, state) => Text(
222 + isHiddenAmount ?? state.isHidden
223 + ? "●●●●●●"
224 + : showSymbol
225 + ? amount.toLocalStringWithSymbol(
226 + fractionalDigits: fractionalDigits,
227 + trimZeros: trimZeros,
228 + useBaseUnit: useBaseUnit ?? state.useBaseUnit(amount.currency),
229 + withSymbolPrefix: withSymbolPrefix,
230 + locale: (locale ?? Localizations.localeOf(context)).toString(),
231 + )
232 + : amount.toLocalStringWithPrecision(
233 + fractionalDigits: fractionalDigits,
234 + trimZeros: trimZeros,
235 + useBaseUnit: useBaseUnit ?? state.useBaseUnit(amount.currency),
236 + locale: (locale ?? Localizations.localeOf(context)).toString(),
237 + ),
238 + style: style,
239 + strutStyle: strutStyle,
240 + textAlign: textAlign,
241 + textDirection: textDirection,
242 + locale: locale,
243 + softWrap: softWrap,
244 + overflow: overflow,
245 + textScaler: textScaler,
246 + maxLines: maxLines,
247 + semanticsLabel: isHiddenAmount ?? state.isHidden
248 + ? (semanticsLabel ?? S.of(context).amount_hidden)
249 + : semanticsLabel,
250 + semanticsIdentifier: semanticsIdentifier,
251 + textWidthBasis: textWidthBasis,
252 + textHeightBehavior: textHeightBehavior,
253 + selectionColor: selectionColor,
254 + ),
255 + );
256 +}
res/values/strings_de.arb
+1
@@ -62,6 +62,7 @@
62 "already_your_username": "Das ist bereits Ihr Benutzername!",
63 "always": "Immer",
64 "amount": "Betrag: ",
65 + "amount_hidden": "Versteckter Betrag",
66 "amount_is_below_minimum_limit": "Ihr Guthaben nach Gebühren wäre geringer als der für den Tausch erforderliche Mindestbetrag (${min})",
67 "amount_is_estimate": "Der Empfangsbetrag ist eine Schätzung",
68 "amount_is_guaranteed": "Der Empfangsbetrag ist garantiert",
res/values/strings_en.arb
+1
@@ -64,6 +64,7 @@
64 "already_your_username": "This is already your username!",
65 "always": "Always",
66 "amount": "Amount: ",
67 + "amount_hidden": "Amount hidden",
68 "amount_is_below_minimum_limit": "Your balance after fees would be less than the minimum amount needed for the exchange (${min})",
69 "amount_is_estimate": "The receive amount is an estimate",
70 "amount_is_guaranteed": "The receive amount is guaranteed",
test/new-ui/widgets/money/currency_symbol_text_test.dart new
+213
@@ -0,0 +1,213 @@
1 +import "package:cake_wallet/entities/balance_display_mode.dart";
2 +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart";
3 +import "package:cake_wallet/new-ui/widgets/money/currency_symbol_text.dart";
4 +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart";
5 +import "package:cw_core/crypto_currency.dart";
6 +import "package:flutter/material.dart";
7 +import "package:flutter_bloc/flutter_bloc.dart";
8 +import "package:flutter_test/flutter_test.dart";
9 +
10 +class FakeMoneySettingsCubit extends Cubit<MoneySettingsState> implements MoneySettingsCubit {
11 + FakeMoneySettingsCubit(super.initialState);
12 +
13 + void setState(MoneySettingsState state) => emit(state);
14 +}
15 +
16 +const _bitcoinMode = MoneySettingsState(
17 + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.bitcoin,
18 + displayMode: BalanceDisplayMode.fullBalance,
19 +);
20 +
21 +const _satoshiMode = MoneySettingsState(
22 + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.satoshi,
23 + displayMode: BalanceDisplayMode.fullBalance,
24 +);
25 +
26 +const _lnSatoshiMode = MoneySettingsState(
27 + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.satoshiForLightning,
28 + displayMode: BalanceDisplayMode.fullBalance,
29 +);
30 +
31 +/// A non-BTC currency whose symbol exceeds the 8-char cap. `raw: -1` keeps it
32 +/// distinct from every real currency, so it never counts as BTC/BTCLN.
33 +const _longSymbol = CryptoCurrency(title: "SUPERLONGTOKEN", name: "superlongtoken", decimals: 8);
34 +
35 +Future<FakeMoneySettingsCubit> _pump(
36 + WidgetTester tester,
37 + Widget child,
38 + MoneySettingsState state,
39 + ) async {
40 + final cubit = FakeMoneySettingsCubit(state);
41 + addTearDown(cubit.close);
42 + await tester.pumpWidget(
43 + MaterialApp(
44 + home: BlocProvider<MoneySettingsCubit>.value(
45 + value: cubit,
46 + child: Scaffold(body: Center(child: child)),
47 + ),
48 + ),
49 + );
50 + return cubit;
51 +}
52 +
53 +void main() {
54 + group("useBaseUnit == null (follows settings)", () {
55 + testWidgets("BTC in satoshi mode -> sats", (tester) async {
56 + await _pump(tester, const CurrencySymbolText(CryptoCurrency.btc), _satoshiMode);
57 +
58 + expect(find.text("sats"), findsOneWidget);
59 + });
60 +
61 + testWidgets("BTC in bitcoin mode -> BTC", (tester) async {
62 + await _pump(tester, const CurrencySymbolText(CryptoCurrency.btc), _bitcoinMode);
63 +
64 + expect(find.text("BTC"), findsOneWidget);
65 + });
66 +
67 + testWidgets("non-BTC currency ignores satoshi mode", (tester) async {
68 + await _pump(tester, const CurrencySymbolText(CryptoCurrency.xmr), _satoshiMode);
69 +
70 + expect(find.text("XMR"), findsOneWidget);
71 + });
72 +
73 + testWidgets("BTCLN in LN-satoshi mode -> sats", (tester) async {
74 + await _pump(
75 + tester,
76 + const CurrencySymbolText(CryptoCurrency.btcln),
77 + _lnSatoshiMode,
78 + );
79 +
80 + expect(find.text("sats"), findsOneWidget);
81 + });
82 +
83 + testWidgets("BTCLN in bitcoin mode -> BTC", (tester) async {
84 + await _pump(tester, const CurrencySymbolText(CryptoCurrency.btcln), _bitcoinMode);
85 +
86 + // BTCLN's title/symbol is "BTC".
87 + expect(find.text("BTC"), findsOneWidget);
88 + });
89 + });
90 +
91 + group("useBaseUnit == true (force base unit where supported)", () {
92 + testWidgets("BTC -> sats regardless of settings", (tester) async {
93 + await _pump(
94 + tester,
95 + const CurrencySymbolText(CryptoCurrency.btc, useBaseUnit: true),
96 + _bitcoinMode, // settings say bitcoin, but override wins
97 + );
98 +
99 + expect(find.text("sats"), findsOneWidget);
100 + });
101 +
102 + testWidgets("BTCLN -> sats", (tester) async {
103 + await _pump(
104 + tester,
105 + const CurrencySymbolText(CryptoCurrency.btcln, useBaseUnit: true),
106 + _bitcoinMode,
107 + );
108 +
109 + expect(find.text("sats"), findsOneWidget);
110 + });
111 +
112 + testWidgets("non-BTC currency keeps its symbol even when forced", (tester) async {
113 + await _pump(
114 + tester,
115 + const CurrencySymbolText(CryptoCurrency.xmr, useBaseUnit: true),
116 + _satoshiMode,
117 + );
118 +
119 + expect(find.text("XMR"), findsOneWidget);
120 + });
121 + });
122 +
123 + group("useBaseUnit == false (always the symbol)", () {
124 + testWidgets("BTC -> BTC even in satoshi mode", (tester) async {
125 + await _pump(
126 + tester,
127 + const CurrencySymbolText(CryptoCurrency.btc, useBaseUnit: false),
128 + _satoshiMode,
129 + );
130 +
131 + expect(find.text("BTC"), findsOneWidget);
132 + expect(find.text("sats"), findsNothing);
133 + });
134 +
135 + testWidgets("BTCLN -> BTC even in LN-satoshi mode", (tester) async {
136 + await _pump(
137 + tester,
138 + const CurrencySymbolText(CryptoCurrency.btcln, useBaseUnit: false),
139 + _lnSatoshiMode,
140 + );
141 +
142 + expect(find.text("BTC"), findsOneWidget);
143 + expect(find.text("sats"), findsNothing);
144 + });
145 +
146 + testWidgets("non-BTC currency -> symbol", (tester) async {
147 + await _pump(
148 + tester,
149 + const CurrencySymbolText(CryptoCurrency.xmr, useBaseUnit: false),
150 + _bitcoinMode,
151 + );
152 +
153 + expect(find.text("XMR"), findsOneWidget);
154 + });
155 + });
156 +
157 + group("symbol formatting", () {
158 + testWidgets("caps symbols longer than 8 characters", (tester) async {
159 + await _pump(
160 + tester,
161 + const CurrencySymbolText(_longSymbol, useBaseUnit: false),
162 + _bitcoinMode,
163 + );
164 +
165 + // "SUPERLONGTOKEN" -> first 8 chars.
166 + expect(find.text("SUPERLON"), findsOneWidget);
167 + });
168 + });
169 +
170 + group("rebuild on cubit state change", () {
171 + testWidgets("re-renders the symbol when settings change", (tester) async {
172 + final cubit = await _pump(
173 + tester,
174 + const CurrencySymbolText(CryptoCurrency.btc),
175 + _bitcoinMode,
176 + );
177 + expect(find.text("BTC"), findsOneWidget);
178 +
179 + cubit.setState(_satoshiMode);
180 + await tester.pump();
181 +
182 + expect(find.text("BTC"), findsNothing);
183 + expect(find.text("sats"), findsOneWidget);
184 + });
185 + });
186 +
187 + group("forwarding to the inner Text", () {
188 + testWidgets("passes through Text properties incl. semanticsIdentifier", (tester) async {
189 + await _pump(
190 + tester,
191 + const CurrencySymbolText(
192 + CryptoCurrency.btc,
193 + maxLines: 2,
194 + textAlign: TextAlign.center,
195 + overflow: TextOverflow.fade,
196 + style: TextStyle(fontSize: 18),
197 + semanticsIdentifier: "currency-symbol",
198 + ),
199 + _bitcoinMode,
200 + );
201 +
202 + final text = tester.widget<Text>(
203 + find.descendant(of: find.byType(CurrencySymbolText), matching: find.byType(Text)),
204 + );
205 +
206 + expect(text.maxLines, 2);
207 + expect(text.textAlign, TextAlign.center);
208 + expect(text.overflow, TextOverflow.fade);
209 + expect(text.style?.fontSize, 18);
210 + expect(text.semanticsIdentifier, "currency-symbol");
211 + });
212 + });
213 +}
test/new-ui/widgets/money/money_text_test.dart new
+340
@@ -0,0 +1,340 @@
1 +import "package:cake_wallet/entities/balance_display_mode.dart";
2 +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart";
3 +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart";
4 +import "package:cake_wallet/new-ui/widgets/money/money_text.dart";
5 +import "package:cw_core/amount/money.dart";
6 +import "package:cw_core/crypto_currency.dart";
7 +import "package:flutter/material.dart";
8 +import "package:flutter_bloc/flutter_bloc.dart";
9 +import "package:flutter_test/flutter_test.dart";
10 +
11 +class FakeMoneySettingsCubit extends Cubit<MoneySettingsState> implements MoneySettingsCubit {
12 + FakeMoneySettingsCubit(super.initialState);
13 +
14 + void setState(MoneySettingsState state) => emit(state);
15 +}
16 +
17 +/// Visible, BTC shown as bitcoin (not base unit).
18 +const _visibleBtc = MoneySettingsState(
19 + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.bitcoin,
20 + displayMode: BalanceDisplayMode.fullBalance,
21 +);
22 +
23 +/// Hidden balance.
24 +const _hiddenBtc = MoneySettingsState(
25 + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.bitcoin,
26 + displayMode: BalanceDisplayMode.hiddenBalance,
27 +);
28 +
29 +/// Visible, satoshi mode -> BTC/BTCLN use base unit.
30 +const _sats = MoneySettingsState(
31 + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.satoshi,
32 + displayMode: BalanceDisplayMode.fullBalance,
33 +);
34 +
35 +/// Visible, LN-only satoshi mode -> only BTCLN uses base unit.
36 +const _lnSats = MoneySettingsState(
37 + bitcoinAmountDisplayMode: BitcoinAmountDisplayMode.satoshiForLightning,
38 + displayMode: BalanceDisplayMode.fullBalance,
39 +);
40 +
41 +// --- Money fixtures --------------------------------------------------------
42 +final _btc012 = Money.fromInt(12345678, CryptoCurrency.btc); // 0.12345678 BTC
43 +final _btcTrailing = Money.fromInt(12000000, CryptoCurrency.btc); // 0.12 BTC
44 +final _btcGrouping = Money.fromInt(123450000000, CryptoCurrency.btc); // 1234.5 BTC
45 +final _xmrHalf = Money.fromInt(500000000000, CryptoCurrency.xmr); // 0.5 XMR
46 +final _btclnAmount = Money.fromInt(12345678, CryptoCurrency.btcln); // 0.12345678 BTC (LN)
47 +
48 +const _hidden = "●●●●●●";
49 +
50 +Future<FakeMoneySettingsCubit> _pump(
51 + WidgetTester tester,
52 + Widget child,
53 + MoneySettingsState state,
54 + ) async {
55 + final cubit = FakeMoneySettingsCubit(state);
56 + addTearDown(cubit.close);
57 + await tester.pumpWidget(
58 + MaterialApp(
59 + home: BlocProvider<MoneySettingsCubit>.value(
60 + value: cubit,
61 + child: Scaffold(body: Center(child: child)),
62 + ),
63 + ),
64 + );
65 + return cubit;
66 +}
67 +
68 +void main() {
69 + group("hidden amount", () {
70 + testWidgets("isHiddenAmount:true hides even when state is visible", (tester) async {
71 + await _pump(tester, MoneyText(_btc012, isHiddenAmount: true), _visibleBtc);
72 +
73 + expect(find.text(_hidden), findsOneWidget);
74 + expect(find.text("0.12345678 BTC"), findsNothing);
75 + });
76 +
77 + testWidgets("isHiddenAmount:false overrides a hidden state", (tester) async {
78 + await _pump(tester, MoneyText(_btc012, isHiddenAmount: false), _hiddenBtc);
79 +
80 + expect(find.text("0.12345678 BTC"), findsOneWidget);
81 + expect(find.text(_hidden), findsNothing);
82 + });
83 +
84 + testWidgets("isHiddenAmount:null falls back to state.isHidden (hidden)", (tester) async {
85 + await _pump(tester, MoneyText(_btc012), _hiddenBtc);
86 +
87 + expect(find.text(_hidden), findsOneWidget);
88 + });
89 +
90 + testWidgets("isHiddenAmount:null falls back to state.isHidden (visible)", (tester) async {
91 + await _pump(tester, MoneyText(_btc012), _visibleBtc);
92 +
93 + expect(find.text("0.12345678 BTC"), findsOneWidget);
94 + expect(find.text(_hidden), findsNothing);
95 + });
96 + });
97 +
98 + group("symbol vs precision", () {
99 + testWidgets("showSymbol:true appends the currency symbol", (tester) async {
100 + await _pump(tester, MoneyText(_btc012), _visibleBtc);
101 +
102 + expect(find.text("0.12345678 BTC"), findsOneWidget);
103 + });
104 +
105 + testWidgets("showSymbol:false omits the currency symbol", (tester) async {
106 + await _pump(tester, MoneyText(_btc012, showSymbol: false), _visibleBtc);
107 +
108 + expect(find.text("0.12345678"), findsOneWidget);
109 + expect(find.text("0.12345678 BTC"), findsNothing);
110 + });
111 + });
112 +
113 + group("withSymbolPrefix", () {
114 + test("defaults to false on the constructor", () {
115 + expect(MoneyText(_btc012).withSymbolPrefix, false);
116 + });
117 +
118 + test("defaults to false via optional", () {
119 + expect((MoneyText.optional(_btc012) as MoneyText).withSymbolPrefix, false);
120 + });
121 +
122 + testWidgets("ignored when showSymbol is false", (tester) async {
123 + await _pump(
124 + tester,
125 + MoneyText(_btc012, showSymbol: false, withSymbolPrefix: true),
126 + _visibleBtc,
127 + );
128 +
129 + expect(find.text("0.12345678"), findsOneWidget);
130 + expect(find.text("BTC 0.12345678"), findsNothing);
131 + expect(find.text("0.12345678 BTC"), findsNothing);
132 + });
133 +
134 + testWidgets("prefixes the symbol before the amount", (tester) async {
135 + await _pump(tester, MoneyText(_btc012, withSymbolPrefix: true), _visibleBtc);
136 +
137 + expect(find.text("BTC 0.12345678"), findsOneWidget);
138 + });
139 +
140 + testWidgets("prefixed amount is still localized (en_US grouping)", (tester) async {
141 + await _pump(tester, MoneyText(_btcGrouping, withSymbolPrefix: true), _visibleBtc);
142 +
143 + expect(find.text("BTC 1,234.5"), findsOneWidget);
144 + });
145 +
146 + testWidgets("prefixed amount respects an explicit de_DE locale", (tester) async {
147 + await _pump(
148 + tester,
149 + MoneyText(_btcGrouping, withSymbolPrefix: true, locale: const Locale("de", "DE")),
150 + _visibleBtc,
151 + );
152 +
153 + expect(find.text("BTC 1.234,5"), findsOneWidget);
154 + });
155 +
156 + testWidgets("prefixes the base-unit ticker (sats)", (tester) async {
157 + await _pump(
158 + tester,
159 + MoneyText(_btc012, withSymbolPrefix: true, useBaseUnit: true),
160 + _visibleBtc,
161 + );
162 +
163 + expect(find.text("sats 12,345,678"), findsOneWidget);
164 + });
165 + });
166 +
167 + group("base unit (useBaseUnit)", () {
168 + testWidgets("explicit useBaseUnit:true renders sats", (tester) async {
169 + await _pump(tester, MoneyText(_btc012, useBaseUnit: true), _visibleBtc);
170 +
171 + // 12345678 sats, localized grouping applied by withLocalSeperator.
172 + expect(find.text("12,345,678 sats"), findsOneWidget);
173 + });
174 +
175 + testWidgets("explicit useBaseUnit:false overrides a satoshi state", (tester) async {
176 + await _pump(tester, MoneyText(_btc012, useBaseUnit: false), _sats);
177 +
178 + expect(find.text("0.12345678 BTC"), findsOneWidget);
179 + });
180 +
181 + testWidgets("useBaseUnit:null -> state decides (BTC in satoshi mode -> sats)", (tester) async {
182 + await _pump(tester, MoneyText(_btc012), _sats);
183 +
184 + expect(find.text("12,345,678 sats"), findsOneWidget);
185 + });
186 +
187 + testWidgets("useBaseUnit:null -> state decides (BTC in bitcoin mode -> BTC)", (tester) async {
188 + await _pump(tester, MoneyText(_btc012), _visibleBtc);
189 +
190 + expect(find.text("0.12345678 BTC"), findsOneWidget);
191 + });
192 +
193 + testWidgets("non-BTC currency ignores satoshi mode", (tester) async {
194 + await _pump(tester, MoneyText(_xmrHalf), _sats);
195 +
196 + expect(find.text("0.5 XMR"), findsOneWidget);
197 + });
198 +
199 + testWidgets("LN-satoshi mode makes BTCLN use base unit", (tester) async {
200 + await _pump(tester, MoneyText(_btclnAmount), _lnSats);
201 +
202 + expect(find.text("12,345,678 sats"), findsOneWidget);
203 + });
204 +
205 + testWidgets("LN-satoshi mode leaves plain BTC untouched", (tester) async {
206 + await _pump(tester, MoneyText(_btc012), _lnSats);
207 +
208 + expect(find.text("0.12345678 BTC"), findsOneWidget);
209 + });
210 + });
211 +
212 + group("fractionalDigits & trimZeros", () {
213 + testWidgets("fractionalDigits truncates (does not round)", (tester) async {
214 + await _pump(tester, MoneyText(_btc012, fractionalDigits: 2), _visibleBtc);
215 +
216 + // 0.12345678 truncated to 2 digits -> 0.12 (NOT 0.13).
217 + expect(find.text("0.12 BTC"), findsOneWidget);
218 + });
219 +
220 + testWidgets("trimZeros:true (default) removes trailing zeros", (tester) async {
221 + await _pump(tester, MoneyText(_btcTrailing), _visibleBtc);
222 +
223 + expect(find.text("0.12 BTC"), findsOneWidget);
224 + });
225 +
226 + testWidgets("trimZeros:false keeps trailing zeros", (tester) async {
227 + await _pump(tester, MoneyText(_btcTrailing, trimZeros: false), _visibleBtc);
228 +
229 + expect(find.text("0.12000000 BTC"), findsOneWidget);
230 + });
231 + });
232 +
233 + group("locale & separators", () {
234 + testWidgets('default locale (en_US) uses "," grouping and "." decimal', (tester) async {
235 + await _pump(tester, MoneyText(_btcGrouping), _visibleBtc);
236 +
237 + expect(find.text("1,234.5 BTC"), findsOneWidget);
238 + });
239 +
240 + testWidgets("explicit de_DE locale swaps grouping/decimal separators", (tester) async {
241 + await _pump(
242 + tester,
243 + MoneyText(_btcGrouping, locale: const Locale("de", "DE")),
244 + _visibleBtc,
245 + );
246 +
247 + expect(find.text("1.234,5 BTC"), findsOneWidget);
248 + });
249 + });
250 +
251 + group("rebuild on cubit state change", () {
252 + testWidgets("re-renders when the settings state changes", (tester) async {
253 + final cubit = await _pump(tester, MoneyText(_btc012), _visibleBtc);
254 + expect(find.text("0.12345678 BTC"), findsOneWidget);
255 +
256 + cubit.setState(_hiddenBtc);
257 + await tester.pump();
258 +
259 + expect(find.text("0.12345678 BTC"), findsNothing);
260 + expect(find.text(_hidden), findsOneWidget);
261 + });
262 + });
263 +
264 + group("forwarding to the inner Text", () {
265 + testWidgets("passes through Text properties incl. semanticsIdentifier", (tester) async {
266 + await _pump(
267 + tester,
268 + MoneyText(
269 + _btc012,
270 + maxLines: 3,
271 + textAlign: TextAlign.right,
272 + overflow: TextOverflow.ellipsis,
273 + style: const TextStyle(fontSize: 42),
274 + semanticsIdentifier: "via-ctor",
275 + ),
276 + _visibleBtc,
277 + );
278 +
279 + final text = tester.widget<Text>(
280 + find.descendant(of: find.byType(MoneyText), matching: find.byType(Text)),
281 + );
282 +
283 + expect(text.maxLines, 3);
284 + expect(text.textAlign, TextAlign.right);
285 + expect(text.overflow, TextOverflow.ellipsis);
286 + expect(text.style?.fontSize, 42);
287 + expect(text.semanticsIdentifier, "via-ctor");
288 + });
289 + });
290 +
291 + group("MoneyText.optional", () {
292 + test("returns SizedBox.shrink for a null amount", () {
293 + final widget = MoneyText.optional(null);
294 +
295 + expect(widget, isA<SizedBox>());
296 + final box = widget as SizedBox;
297 + expect(box.width, 0.0);
298 + expect(box.height, 0.0);
299 + });
300 +
301 + test("returns a MoneyText for a non-null amount", () {
302 + expect(MoneyText.optional(_btc012), isA<MoneyText>());
303 + });
304 +
305 + test("forwards money-specific parameters to MoneyText", () {
306 + final widget = MoneyText.optional(
307 + _btc012,
308 + showSymbol: false,
309 + fractionalDigits: 3,
310 + trimZeros: false,
311 + isHiddenAmount: true,
312 + useBaseUnit: true,
313 + withSymbolPrefix: true,
314 + ) as MoneyText;
315 +
316 + expect(widget.showSymbol, false);
317 + expect(widget.fractionalDigits, 3);
318 + expect(widget.trimZeros, false);
319 + expect(widget.isHiddenAmount, true);
320 + expect(widget.useBaseUnit, true);
321 + expect(widget.withSymbolPrefix, true);
322 + });
323 +
324 + testWidgets("renders identically to a direct MoneyText", (tester) async {
325 + await _pump(tester, MoneyText.optional(_btc012), _visibleBtc);
326 +
327 + expect(find.byType(MoneyText), findsOneWidget);
328 + expect(find.text("0.12345678 BTC"), findsOneWidget);
329 + });
330 +
331 + test(
332 + "forwards semanticsIdentifier to MoneyText",
333 + () {
334 + final widget = MoneyText.optional(_btc012, semanticsIdentifier: "sid") as MoneyText;
335 +
336 + expect(widget.semanticsIdentifier, "sid");
337 + },
338 + );
339 + });
340 +}