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