diff --git a/lib/new-ui/widgets/money/money_settings_provider.dart b/lib/new-ui/widgets/money/money_settings_provider.dart index 17831a28a..93f6b21cd 100644 --- a/lib/new-ui/widgets/money/money_settings_provider.dart +++ b/lib/new-ui/widgets/money/money_settings_provider.dart @@ -1,15 +1,20 @@ import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cake_wallet/new-ui/widgets/money/use_base_unit_provider.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}); + const MoneySettingsProvider({required this.settingsStore, required this.child, super.key}); - final Widget? child; + final Widget child; final SettingsStore settingsStore; @override - Widget build(BuildContext context) => - BlocProvider(create: (_) => MoneySettingsCubit(settingsStore), child: child); + Widget build(BuildContext context) => BlocProvider( + create: (_) => MoneySettingsCubit(settingsStore), + child: BaseUnitScope( + child: child, + ), + ); } diff --git a/lib/new-ui/widgets/money/use_base_unit_provider.dart b/lib/new-ui/widgets/money/use_base_unit_provider.dart new file mode 100644 index 000000000..e04e47350 --- /dev/null +++ b/lib/new-ui/widgets/money/use_base_unit_provider.dart @@ -0,0 +1,63 @@ +import "dart:async"; + +import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart"; +import "package:cw_core/currency.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; + +/// Read-only view of the money display settings for the current subtree. +/// +/// ```dart +/// final useBaseUnit = BaseUnit.useBaseUnitOf(context, CryptoCurrency.btc); +/// ``` +class BaseUnit extends InheritedModel { + const BaseUnit({required this.state, required super.child}); + + final MoneySettingsState state; + + static bool useBaseUnitOf(BuildContext context, Currency currency) => + InheritedModel.inheritFrom(context, aspect: currency)!.state.useBaseUnit(currency); + + static String getSymbolOf(BuildContext context, Currency currency) => + InheritedModel.inheritFrom(context, aspect: currency)!.state.getSymbol(currency); + + @override + bool updateShouldNotify(BaseUnit old) => + state.bitcoinAmountDisplayMode != old.state.bitcoinAmountDisplayMode; + + @override + bool updateShouldNotifyDependent(BaseUnit old, Set aspects) => aspects.any((c) => + state.useBaseUnit(c) != old.state.useBaseUnit(c) || + state.getSymbol(c) != old.state.getSymbol(c)); +} + +class BaseUnitScope extends StatefulWidget { + const BaseUnitScope({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _BaseUnitScopeState(); +} + +class _BaseUnitScopeState extends State { + late MoneySettingsState _state; + late final StreamSubscription _sub; + + @override + void initState() { + super.initState(); + final cubit = context.read(); + _state = cubit.state; + _sub = cubit.stream.listen((s) => setState(() => _state = s)); + } + + @override + void dispose() { + _sub.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BaseUnit(state: _state, child: widget.child); +} diff --git a/test/new-ui/widgets/money/use_base_unit_provider_test.dart b/test/new-ui/widgets/money/use_base_unit_provider_test.dart new file mode 100644 index 000000000..ea340cc2d --- /dev/null +++ b/test/new-ui/widgets/money/use_base_unit_provider_test.dart @@ -0,0 +1,377 @@ +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/use_base_unit_provider.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:flutter_test/flutter_test.dart"; + +class FakeMoneySettingsCubit extends Cubit implements MoneySettingsCubit { + FakeMoneySettingsCubit(super.initialState); + + void setState(MoneySettingsState state) => emit(state); +} + +MoneySettingsState _moneySettingsState({ + BitcoinAmountDisplayMode bitcoin = BitcoinAmountDisplayMode.bitcoin, + BalanceDisplayMode display = BalanceDisplayMode.availableBalance, +}) => + MoneySettingsState( + bitcoinAmountDisplayMode: bitcoin, + displayMode: display, + ); + +Future _pumpEmission(WidgetTester tester) => tester.pump(Duration.zero); + +class _Probe extends StatelessWidget { + const _Probe({required this.currencies, required this.log}); + + _Probe.single(Currency currency, {required this.log, super.key}) + : currencies = [currency]; + + final List currencies; + final List> log; + + @override + Widget build(BuildContext context) { + final observed = + currencies.map((c) => BaseUnit.useBaseUnitOf(context, c)).toList(growable: false); + log.add(observed); + return const SizedBox.shrink(); + } +} + +class _SymbolProbe extends StatelessWidget { + const _SymbolProbe(this.currency, this.log, {super.key}); + + final Currency currency; + final List log; + + @override + Widget build(BuildContext context) { + log.add(BaseUnit.getSymbolOf(context, currency)); + return const SizedBox.shrink(); + } +} + +class _SubtreeCounter extends StatelessWidget { + const _SubtreeCounter(this.log, {required this.child}); + + final List log; + final Widget child; + + @override + Widget build(BuildContext context) { + log.add(null); + return child; + } +} + +Widget _host(FakeMoneySettingsCubit cubit, Widget child) => MaterialApp( + home: + BlocProvider.value(value: cubit, child: BaseUnitScope(child: child)), + ); + +void main() { + group("BaseUnit", () { + group("BaseUnit.useBaseUnitOf", () { + testWidgets("reads the value from the nearest ancestor", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + ); + final log = >[]; + + await tester.pumpWidget( + _host(cubit, _Probe.single(CryptoCurrency.btc, log: log)), + ); + + expect(log, [[true]]); + }); + + testWidgets("nearest ancestor wins when models are nested", (tester) async { + final log = >[]; + + await tester.pumpWidget( + BaseUnit( + state: _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + child: BaseUnit( + state: _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + child: _Probe.single(CryptoCurrency.btc, log: log), + ), + ), + ); + + expect(log.single, [false]); + }); + + testWidgets("throws when no BaseUnit ancestor exists", (tester) async { + await tester.pumpWidget(_Probe.single(CryptoCurrency.btc, log: >[])); + + expect(tester.takeException(), isA()); + }); + }); + + group("BaseUnit.getSymbolOf", () { + testWidgets("returns the ticker under whole-coin mode", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = []; + + await tester.pumpWidget(_host(cubit, _SymbolProbe(CryptoCurrency.btc, log))); + + expect(log.single, CryptoCurrency.btc.symbol); + }); + + testWidgets("flips to sats when the mode changes", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = []; + + await tester.pumpWidget(_host(cubit, _SymbolProbe(CryptoCurrency.btc, log))); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi)); + await _pumpEmission(tester); + + expect(log, [CryptoCurrency.btc.symbol, "sats"]); + }); + + testWidgets("spares a symbol reader whose currency did not change", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + ); + final btcLog = []; + final btclnLog = []; + + await tester.pumpWidget( + _host( + cubit, + Row( + children: [ + _SymbolProbe(CryptoCurrency.btc, btcLog, key: const ValueKey("btc")), + _SymbolProbe(CryptoCurrency.btcln, btclnLog, key: const ValueKey("btcln")), + ], + ), + ), + ); + expect(btcLog.single, "sats"); + expect(btclnLog.single, "sats"); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshiForLightning)); + await _pumpEmission(tester); + + expect(btcLog.length, 2); + expect(btcLog.last, CryptoCurrency.btc.symbol); + + expect(btclnLog.length, 1, reason: "btcln symbol did not change"); + }); + + testWidgets("a non-bitcoin symbol is stable across every mode", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = []; + + await tester.pumpWidget(_host(cubit, _SymbolProbe(CryptoCurrency.xmr, log))); + + for (final mode in BitcoinAmountDisplayMode.all) { + cubit.setState(_moneySettingsState(bitcoin: mode)); + await _pumpEmission(tester); + } + + expect(log.single, CryptoCurrency.xmr.symbol); + }); + }); + + group("BaseUnit aspect notification", () { + testWidgets("rebuilds a dependent whose currency flipped", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = >[]; + + await tester.pumpWidget(_host(cubit, _Probe.single(CryptoCurrency.btc, log: log))); + expect(log.length, 1); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi)); + await _pumpEmission(tester); + + expect(log.length, 2); + expect(log.last, [true]); + }); + + testWidgets("satoshi -> satoshiForLightning rebuilds btc but not btcln", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + ); + final btcLog = >[]; + final btclnLog = >[]; + + await tester.pumpWidget( + _host( + cubit, + Row( + children: [ + _Probe.single(CryptoCurrency.btc, log: btcLog, key: const ValueKey("btc")), + _Probe.single(CryptoCurrency.btcln, log: btclnLog, key: const ValueKey("btcln")), + ], + ), + ), + ); + expect(btcLog.single, [true]); + expect(btclnLog.single, [true]); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshiForLightning)); + await _pumpEmission(tester); + + // btc: true -> false, so it must rebuild. + expect(btcLog.length, 2); + expect(btcLog.last, [false]); + + // btcln: true -> true. The aspect filter should have spared it. + expect(btclnLog.length, 1, reason: "btcln value did not change"); + }); + + testWidgets("a currency outside the bitcoin family never rebuilds", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final log = >[]; + + await tester.pumpWidget(_host(cubit, _Probe.single(CryptoCurrency.xmr, log: log))); + + for (final mode in BitcoinAmountDisplayMode.all) { + cubit.setState(_moneySettingsState(bitcoin: mode)); + await _pumpEmission(tester); + } + + expect(log.length, 1); + }); + + testWidgets("a dependent on several currencies rebuilds if any changed", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi), + ); + final log = >[]; + + await tester.pumpWidget( + _host( + cubit, + _Probe(currencies: [CryptoCurrency.xmr, CryptoCurrency.btc], log: log), + ), + ); + expect(log.single, [false, true]); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshiForLightning)); + await _pumpEmission(tester); + + expect(log.length, 2); + expect(log.last, [false, false]); + }); + + testWidgets("a value-equal but distinct state does not reach dependents", (tester) async { + final initial = _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi); + final cubit = FakeMoneySettingsCubit(initial); + final log = >[]; + + await tester.pumpWidget(_host(cubit, _Probe.single(CryptoCurrency.btc, log: log))); + expect(log.length, 1); + + cubit.setState(initial.copyWith()); + await _pumpEmission(tester); + + expect(log.length, 1); + }); + + testWidgets("changing only displayMode does not reach useBaseUnit dependents", + (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState( + bitcoin: BitcoinAmountDisplayMode.satoshi, + display: BalanceDisplayMode.availableBalance, + ), + ); + final log = >[]; + + await tester.pumpWidget( + _host(cubit, _Probe.single(CryptoCurrency.btc, log: log)), + ); + + cubit.setState( + _moneySettingsState( + bitcoin: BitcoinAmountDisplayMode.satoshi, + display: BalanceDisplayMode.hiddenBalance, + ), + ); + await _pumpEmission(tester); + + expect(log.length, 1); + }); + }); + + group("BaseUnitScope subtree isolation", () { + testWidgets("an emission does not rebuild the subtree", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final subtreeLog = []; + final probeLog = >[]; + + await tester.pumpWidget( + _host( + cubit, + _SubtreeCounter( + subtreeLog, + child: _Probe.single(CryptoCurrency.btc, log: probeLog), + ), + ), + ); + expect(subtreeLog.length, 1); + expect(probeLog.length, 1); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi)); + await _pumpEmission(tester); + + expect(probeLog.length, 2); + expect(probeLog.last, [true]); + + expect(subtreeLog.length, 1, reason: "the subtree was rebuilt"); + }); + + testWidgets("nested subtrees below the boundary are untouched", (tester) async { + final cubit = FakeMoneySettingsCubit( + _moneySettingsState(bitcoin: BitcoinAmountDisplayMode.bitcoin), + ); + final outerLog = []; + final innerLog = []; + final probeLog = >[]; + + await tester.pumpWidget( + _host( + cubit, + _SubtreeCounter( + outerLog, + child: Padding( + padding: EdgeInsets.zero, + child: _SubtreeCounter( + innerLog, + child: _Probe.single(CryptoCurrency.btc, log: probeLog), + ), + ), + ), + ), + ); + + cubit.setState(_moneySettingsState(bitcoin: BitcoinAmountDisplayMode.satoshi)); + await _pumpEmission(tester); + + expect(probeLog.length, 2); + expect(outerLog.length, 1); + expect(innerLog.length, 1); + }); + }); + }); +}