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
+}