diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 61f8ae5fbe..5d396d442b 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -42,6 +42,7 @@ import '../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dar import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; +import '../../widgets/address_label_editor.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -161,8 +162,7 @@ class _ReceiveViewState extends ConsumerState { if (slatepackString == null) return; if (mounted) { - final wallet = - ref.read(pWallets).getWallet(walletId) as EpiccashWallet; + final wallet = ref.read(pWallets).getWallet(walletId) as EpiccashWallet; Exception? ex; final result = await showLoading( @@ -226,9 +226,10 @@ class _ReceiveViewState extends ConsumerState { return WillPopScope( onWillPop: () async => shouldPop, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.5), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), child: const CustomLoadingOverlay( message: "Generating address", eventBus: null, @@ -285,9 +286,8 @@ class _ReceiveViewState extends ConsumerState { shouldPop = true; if (mounted) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(ReceiveView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(ReceiveView.routeName)); setState(() { _addressMap[_walletAddressTypes[_currentIndex]] = @@ -308,9 +308,10 @@ class _ReceiveViewState extends ConsumerState { return WillPopScope( onWillPop: () async => shouldPop, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.5), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), child: const CustomLoadingOverlay( message: "Generating address", eventBus: null, @@ -553,17 +554,16 @@ class _ReceiveViewState extends ConsumerState { child: AspectRatio( aspectRatio: 1, child: AppBarIconButton( - semanticsLabel: - "Address List Pop-up Button. Opens A Pop-up For Address List Button.", + semanticsLabel: "Address List Pop-up Button. Opens A Pop-up For Address List Button.", key: const Key("walletNetworkSettingsAddNewNodeViewButton"), size: 36, shadows: const [], color: Theme.of(context).extension()!.background, icon: SvgPicture.asset( Assets.svg.verticalEllipsis, - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, width: 20, height: 20, ), @@ -580,9 +580,9 @@ class _ReceiveViewState extends ConsumerState { right: 10, child: Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.popupBG, + color: Theme.of(context) + .extension()! + .popupBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -651,9 +651,9 @@ class _ReceiveViewState extends ConsumerState { Text( "Address type", style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.infoItemLabel, + color: Theme.of(context) + .extension()! + .infoItemLabel, ), ), const SizedBox(height: 10), @@ -792,6 +792,14 @@ class _ReceiveViewState extends ConsumerState { ), ), const SizedBox(height: 12), + RoundedWhiteContainer( + child: AddressLabelEditor( + walletId: walletId, + address: address, + isDesktop: false, + ), + ), + const SizedBox(height: 12), PrimaryButton( label: "Copy address", onPressed: () { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index eb354eb1e0..b4b15aacbb 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -49,6 +49,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interfa import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; +import '../../../../widgets/address_label_editor.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/custom_loading_overlay.dart'; @@ -174,8 +175,7 @@ class _DesktopReceiveState extends ConsumerState { } Future _onEpicReceiveSlatePressed() async { - final wallet = - ref.read(pWallets).getWallet(walletId) as EpiccashWallet; + final wallet = ref.read(pWallets).getWallet(walletId) as EpiccashWallet; Exception? ex; final result = await showLoading( @@ -244,9 +244,10 @@ class _DesktopReceiveState extends ConsumerState { return WillPopScope( onWillPop: () async => shouldPop, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.5), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), child: const CustomLoadingOverlay( message: "Generating address", eventBus: null, @@ -323,9 +324,10 @@ class _DesktopReceiveState extends ConsumerState { return WillPopScope( onWillPop: () async => shouldPop, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.5), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), child: const CustomLoadingOverlay( message: "Generating address", eventBus: null, @@ -557,23 +559,22 @@ class _DesktopReceiveState extends ConsumerState { child: Container( decoration: BoxDecoration( color: - Theme.of( - context, - ).extension()?.textFieldDefaultBG ?? + Theme.of(context) + .extension() + ?.textFieldDefaultBG ?? Colors.white, // Fallback color borderRadius: BorderRadius.circular(8), border: Border.all( color: - Theme.of( - context, - ).extension()?.backgroundAppBar ?? + Theme.of(context) + .extension() + ?.backgroundAppBar ?? Colors.grey, // Fallback color width: 1, ), ), child: const SizedBox( - height: - 60, // Provide an explicit height to avoid infinite constraints + height: 60, // Provide an explicit height to avoid infinite constraints child: MwcTxsMethodToggle(), ), ), @@ -584,30 +585,31 @@ class _DesktopReceiveState extends ConsumerState { child: Container( decoration: BoxDecoration( color: - Theme.of( - context, - ).extension()?.textFieldDefaultBG ?? + Theme.of(context) + .extension() + ?.textFieldDefaultBG ?? Colors.white, // Fallback color borderRadius: BorderRadius.circular(8), border: Border.all( color: - Theme.of( - context, - ).extension()?.backgroundAppBar ?? + Theme.of(context) + .extension() + ?.backgroundAppBar ?? Colors.grey, // Fallback color width: 1, ), ), child: const SizedBox( - height: - 60, // Provide an explicit height to avoid infinite constraints + height: 60, // Provide an explicit height to avoid infinite constraints child: EpicTxsMethodToggle(), ), ), ), - if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) + if (!((isMimblewimblecoin || isEpiccash) && + ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 20), - if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) + if (!((isMimblewimblecoin || isEpiccash) && + ref.watch(pIsSlatepack(widget.walletId)))) ConditionalParent( condition: showMultiType, builder: (child) => Column( @@ -652,9 +654,9 @@ class _DesktopReceiveState extends ConsumerState { ), buttonStyleData: ButtonStyleData( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -664,9 +666,9 @@ class _DesktopReceiveState extends ConsumerState { offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -699,9 +701,9 @@ class _DesktopReceiveState extends ConsumerState { child: Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of(context) + .extension()! + .backgroundAppBar, width: 1, ), borderRadius: BorderRadius.circular( @@ -726,9 +728,9 @@ class _DesktopReceiveState extends ConsumerState { Assets.svg.copy, width: 15, height: 15, - color: Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of(context) + .extension()! + .infoItemIcons, ), const SizedBox(width: 4), Text("Copy", style: STextStyles.link2(context)), @@ -746,9 +748,9 @@ class _DesktopReceiveState extends ConsumerState { STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ), @@ -762,6 +764,28 @@ class _DesktopReceiveState extends ConsumerState { ), ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context) + .extension()! + .backgroundAppBar, + width: 1, + ), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: RoundedWhiteContainer( + child: AddressLabelEditor( + walletId: walletId, + address: address, + isDesktop: true, + ), + ), + ), + if (canGen) const SizedBox(height: 20), if (canGen) @@ -778,7 +802,8 @@ class _DesktopReceiveState extends ConsumerState { label: "Generate new address", ), const SizedBox(height: 20), - if ((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))) + if ((isMimblewimblecoin || isEpiccash) && + ref.watch(pIsSlatepack(widget.walletId))) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -786,9 +811,9 @@ class _DesktopReceiveState extends ConsumerState { Text( "Receive Slatepack", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ), textAlign: TextAlign.left, ), @@ -819,9 +844,9 @@ class _DesktopReceiveState extends ConsumerState { }, focusNode: _slateFocusNode, style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, height: 1.8, ), decoration: @@ -833,8 +858,7 @@ class _DesktopReceiveState extends ConsumerState { ).copyWith( contentPadding: const EdgeInsets.symmetric( horizontal: 16, - vertical: - 12, // Adjust vertical padding for better alignment + vertical: 12, // Adjust vertical padding for better alignment ), suffixIcon: Padding( padding: _receiveSlateController.text.isEmpty @@ -909,7 +933,8 @@ class _DesktopReceiveState extends ConsumerState { // TODO: create transparent button class to account for hover // Conditional logic for 'Submit' button or QR code - if ((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))) + if ((isMimblewimblecoin || isEpiccash) && + ref.watch(pIsSlatepack(widget.walletId))) Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: PrimaryButton( @@ -917,7 +942,9 @@ class _DesktopReceiveState extends ConsumerState { label: "Receive Slatepack", enabled: _slateToggleFlag, onPressed: _slateToggleFlag - ? (isEpiccash ? _onEpicReceiveSlatePressed : _onReceiveSlatePressed) + ? (isEpiccash + ? _onEpicReceiveSlatePressed + : _onReceiveSlatePressed) : null, ), ) @@ -986,17 +1013,17 @@ class _DesktopReceiveState extends ConsumerState { Assets.svg.qrcode, width: 14, height: 16, - color: Theme.of( - context, - ).extension()!.accentColorBlue, + color: Theme.of(context) + .extension()! + .accentColorBlue, ), const SizedBox(width: 8), Text( "Create new QR code", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorBlue, + color: Theme.of(context) + .extension()! + .accentColorBlue, ), ), ], diff --git a/lib/providers/wallet/address_label_provider.dart b/lib/providers/wallet/address_label_provider.dart new file mode 100644 index 0000000000..dfc225df34 --- /dev/null +++ b/lib/providers/wallet/address_label_provider.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../models/isar/models/address_label.dart'; +import '../db/main_db_provider.dart'; + +typedef AddressLabelKey = ({String walletId, String address}); + +abstract interface class AddressLabelStore { + AddressLabel? find(AddressLabelKey key); + + Stream> watch(AddressLabelKey key); +} + +class _MainDBAddressLabelStore implements AddressLabelStore { + const _MainDBAddressLabelStore(this.isar); + + final Isar isar; + + // (addressString, walletId) is a unique composite index, so at most one row + // can match a key. + QueryBuilder _query( + AddressLabelKey key, + ) => isar.addressLabels.where().addressStringWalletIdEqualTo( + key.address, + key.walletId, + ); + + @override + AddressLabel? find(AddressLabelKey key) => _query(key).findFirstSync(); + + @override + Stream> watch(AddressLabelKey key) => + _query(key).watch(fireImmediately: true); +} + +final addressLabelStoreProvider = Provider((ref) { + return _MainDBAddressLabelStore(ref.watch(mainDBProvider).isar); +}); + +class _AddressLabelWatcher extends ChangeNotifier { + // The initial value is read synchronously because the query stream only + // delivers its first result asynchronously, while the card has to render in + // the frame this watcher is created in. + _AddressLabelWatcher(AddressLabelStore store, AddressLabelKey key) + : _value = store.find(key) { + _subscription = store + .watch(key) + .listen( + (labels) { + _value = labels.firstOrNull; + notifyListeners(); + }, + // Keep the last known value and stay subscribed when a query fails + // (e.g. the db is closed under a visible card); without a handler the + // failure escapes to the root zone. Never log the key: it contains a + // wallet address. + onError: (Object error) { + debugPrint("address label watch failed: $error"); + }, + ); + } + + late final StreamSubscription> _subscription; + AddressLabel? _value; + + AddressLabel? get value => _value; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} + +final _addressLabelWatcherProvider = ChangeNotifierProvider.autoDispose + .family<_AddressLabelWatcher, AddressLabelKey>((ref, key) { + return _AddressLabelWatcher(ref.watch(addressLabelStoreProvider), key); + }); + +/// The label row of an address, or null when there is none. +/// +/// A row whose `value` is empty is a normal state — opening the address details +/// view creates one — so consumers must check `value.isNotEmpty` rather than +/// treating a non-null row as "has a label". +final pAddressLabel = Provider.autoDispose + .family( + (ref, key) => ref.watch(_addressLabelWatcherProvider(key)).value, + ); diff --git a/lib/widgets/address_label_editor.dart b/lib/widgets/address_label_editor.dart new file mode 100644 index 0000000000..3c086ed1f7 --- /dev/null +++ b/lib/widgets/address_label_editor.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../db/isar/main_db.dart'; +import '../models/isar/models/address_label.dart'; +import '../notifications/show_flush_bar.dart'; +import '../providers/db/main_db_provider.dart'; +import '../providers/wallet/address_label_provider.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/logger.dart'; +import '../utilities/text_styles.dart'; +import 'custom_buttons/simple_edit_button.dart'; + +abstract interface class AddressLabelWriter { + Future write(AddressLabelKey key, String value); +} + +class _MainDBAddressLabelWriter implements AddressLabelWriter { + const _MainDBAddressLabelWriter(this.db); + + final MainDB db; + + @override + Future write(AddressLabelKey key, String value) async { + final existing = db.getAddressLabelSync(key.walletId, key.address); + await db.putAddressLabel( + existing?.copyWith(label: value) ?? + AddressLabel( + walletId: key.walletId, + addressString: key.address, + value: value, + tags: null, + ), + ); + } +} + +final addressLabelWriterProvider = Provider((ref) { + return _MainDBAddressLabelWriter(ref.watch(mainDBProvider)); +}); + +class AddressLabelEditor extends ConsumerStatefulWidget { + const AddressLabelEditor({ + super.key, + required this.walletId, + required this.address, + required this.isDesktop, + }); + + final String walletId; + final String address; + final bool isDesktop; + + @override + ConsumerState createState() => _AddressLabelEditorState(); +} + +class _AddressLabelEditorState extends ConsumerState { + Future _pendingWrite = Future.value(); + + // Each write is a read-then-put on the unique (address, wallet) index, so + // writes are chained: two concurrent creates would race and the loser would + // throw. The key and writer are bound when the edit starts rather than when + // the edit view returns, because the receiving address can rotate while it + // is open and this state may be unmounted by then. + void _queueWrite( + AddressLabelKey key, + AddressLabelWriter writer, + String value, + ) { + _pendingWrite = _pendingWrite.then((_) async { + try { + await writer.write(key, value); + } catch (error, stackTrace) { + Logging.instance.w( + 'Failed to update address label', + error: error, + stackTrace: stackTrace, + ); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: 'Failed to update address label', + context: context, + ), + ); + } + } + }); + } + + @override + Widget build(BuildContext context) { + final key = (walletId: widget.walletId, address: widget.address); + final writer = ref.watch(addressLabelWriterProvider); + final value = ref.watch(pAddressLabel(key))?.value ?? ''; + final style = widget.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ) + : STextStyles.itemSubtitle(context); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(value.isEmpty ? 'No label' : value, style: style)), + SimpleEditButton( + editValue: value, + editLabel: 'label', + overrideTitle: 'Edit label', + onValueChanged: (newValue) => _queueWrite(key, writer, newValue), + ), + ], + ); + } +} diff --git a/test/providers/wallet/address_label_provider_test.dart b/test/providers/wallet/address_label_provider_test.dart new file mode 100644 index 0000000000..07abb6302c --- /dev/null +++ b/test/providers/wallet/address_label_provider_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/address_label.dart'; +import 'package:stackwallet/providers/wallet/address_label_provider.dart'; + +void main() { + late _FakeAddressLabelStore store; + + setUp(() => store = _FakeAddressLabelStore()); + tearDown(() => store.dispose()); + + testWidgets('updates a visible label without another data event', ( + tester, + ) async { + store.value = _label('Original label'); + + await tester.pumpWidget( + ProviderScope( + overrides: [addressLabelStoreProvider.overrideWithValue(store)], + child: const MaterialApp(home: _LabelText()), + ), + ); + expect(find.text('Original label'), findsOneWidget); + + store.emit(_label('Updated label')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Updated label'), findsOneWidget); + expect(find.text('Original label'), findsNothing); + }); + + testWidgets('reacts when a label is created and deleted', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [addressLabelStoreProvider.overrideWithValue(store)], + child: const MaterialApp(home: _LabelText()), + ), + ); + expect(find.text('No label'), findsOneWidget); + + store.emit(_label('New label')); + await tester.pump(); + await tester.pump(); + expect(find.text('New label'), findsOneWidget); + + store.emit(null); + await tester.pump(); + await tester.pump(); + expect(find.text('No label'), findsOneWidget); + }); + + test( + 'a failing watch stream does not escape and does not stop the watcher', + () async { + store.value = _label('Original label'); + + Object? escaped; + AddressLabel? afterError; + late final ProviderContainer container; + + await runZonedGuarded(() async { + container = ProviderContainer( + overrides: [addressLabelStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final sub = container.listen( + pAddressLabel(_key), + (_, __) {}, + ); + addTearDown(sub.close); + + store.emitError(StateError('db closed')); + await Future.delayed(Duration.zero); + afterError = container.read(pAddressLabel(_key)); + + store.emit(_label('Updated label')); + await Future.delayed(Duration.zero); + }, (error, _) => escaped = error); + + expect(escaped, isNull); + expect(afterError?.value, 'Original label'); + expect(container.read(pAddressLabel(_key))?.value, 'Updated label'); + }, + ); +} + +const _key = (walletId: 'wallet', address: 'address'); + +AddressLabel _label(String value) => AddressLabel( + walletId: 'wallet', + addressString: 'address', + value: value, + tags: null, +); + +class _FakeAddressLabelStore implements AddressLabelStore { + final _controller = StreamController>.broadcast(); + AddressLabel? value; + + @override + AddressLabel? find(AddressLabelKey key) => value; + + @override + Stream> watch(AddressLabelKey key) => _controller.stream; + + void emit(AddressLabel? label) { + value = label; + _controller.add(label == null ? [] : [label]); + } + + void emitError(Object error) => _controller.addError(error); + + void dispose() => _controller.close(); +} + +class _LabelText extends ConsumerWidget { + const _LabelText(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final label = ref.watch( + pAddressLabel((walletId: 'wallet', address: 'address')), + ); + return Text(label?.value ?? 'No label'); + } +} diff --git a/test/widget_tests/address_label_editor_test.dart b/test/widget_tests/address_label_editor_test.dart new file mode 100644 index 0000000000..e21ff90eee --- /dev/null +++ b/test/widget_tests/address_label_editor_test.dart @@ -0,0 +1,227 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/address_label.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/providers/wallet/address_label_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/address_label_editor.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_edit_button.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + late _FakeAddressLabelStore store; + late _FakeAddressLabelWriter writer; + + setUp(() { + store = _FakeAddressLabelStore(); + writer = _FakeAddressLabelWriter(); + }); + tearDown(() { + Util.screenWidth = null; + store.dispose(); + }); + + for (final isDesktop in [false, true]) { + testWidgets( + '${isDesktop ? 'desktop' : 'mobile'} editor follows external updates', + (tester) async { + Util.screenWidth = isDesktop ? null : 400; + store.values[_key] = _label('Original'); + await _pumpEditor(tester, store, writer, isDesktop: isDesktop); + expect(find.text('Original'), findsOneWidget); + + store.emit(_key, _label('External update')); + await tester.pump(); + await tester.pump(); + + expect(find.text('External update'), findsOneWidget); + expect(find.text('Original'), findsNothing); + }, + ); + } + + testWidgets('serializes rapid create, update, and clear writes', ( + tester, + ) async { + final firstWrite = Completer(); + writer.onWrite = (key, value) async { + if (value == 'First') { + await firstWrite.future; + } + store.emit(key, _label(value)); + }; + await _pumpEditor(tester, store, writer, isDesktop: true); + + final editButton = tester.widget( + find.byType(SimpleEditButton), + ); + editButton.onValueChanged!('First'); + editButton.onValueChanged!('Second'); + await tester.pump(); + expect(writer.values, ['First']); + + firstWrite.complete(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(writer.values, ['First', 'Second']); + expect(find.text('Second'), findsOneWidget); + + tester + .widget(find.byType(SimpleEditButton)) + .onValueChanged!(''); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(find.text('No label'), findsOneWidget); + }); + + testWidgets('reports a write failure and accepts the next edit', ( + tester, + ) async { + var shouldFail = true; + writer.onWrite = (key, value) async { + if (shouldFail) { + shouldFail = false; + throw StateError('write failed'); + } + store.emit(key, _label(value)); + }; + await _pumpEditor(tester, store, writer, isDesktop: true); + + var editButton = tester.widget( + find.byType(SimpleEditButton), + ); + editButton.onValueChanged!('Fails'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Failed to update address label'), findsOneWidget); + + editButton = tester.widget(find.byType(SimpleEditButton)); + editButton.onValueChanged!('Recovered'); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(find.text('Recovered'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + }); + + testWidgets('writes to the address the edit was started on', (tester) async { + await _pumpEditor(tester, store, writer, isDesktop: true, address: 'A'); + final onValueChanged = tester + .widget(find.byType(SimpleEditButton)) + .onValueChanged!; + + // A wallet refresh rotates the receiving address while the edit view is + // open, rebuilding this editor in place. + await _pumpEditor(tester, store, writer, isDesktop: true, address: 'B'); + onValueChanged('Invoice #1'); + await tester.pump(); + await tester.pump(); + + expect(writer.keys.single.address, 'A'); + expect(writer.values, ['Invoice #1']); + }); + + testWidgets('persists an edit returned after the editor is disposed', ( + tester, + ) async { + await _pumpEditor(tester, store, writer, isDesktop: true); + final onValueChanged = tester + .widget(find.byType(SimpleEditButton)) + .onValueChanged!; + + await tester.pumpWidget(const SizedBox()); + onValueChanged('Late'); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(writer.values, ['Late']); + }); +} + +const _address = 'address'; +const _key = (walletId: 'wallet', address: _address); + +AddressLabel _label(String value) => AddressLabel( + walletId: _key.walletId, + addressString: _key.address, + value: value, + tags: null, +); + +Future _pumpEditor( + WidgetTester tester, + _FakeAddressLabelStore store, + _FakeAddressLabelWriter writer, { + required bool isDesktop, + String address = _address, +}) => tester.pumpWidget( + ProviderScope( + overrides: [ + addressLabelStoreProvider.overrideWithValue(store), + addressLabelWriterProvider.overrideWithValue(writer), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: AddressLabelEditor( + walletId: _key.walletId, + address: address, + isDesktop: isDesktop, + ), + ), + ), + ), +); + +class _FakeAddressLabelStore implements AddressLabelStore { + final values = {}; + final _controllers = + >>{}; + + @override + AddressLabel? find(AddressLabelKey key) => values[key]; + + @override + Stream> watch(AddressLabelKey key) => _controllers + .putIfAbsent(key, StreamController>.broadcast) + .stream; + + void emit(AddressLabelKey key, AddressLabel? label) { + values[key] = label; + _controllers[key]?.add(label == null ? [] : [label]); + } + + void dispose() { + for (final controller in _controllers.values) { + controller.close(); + } + } +} + +class _FakeAddressLabelWriter implements AddressLabelWriter { + Future Function(AddressLabelKey key, String value)? onWrite; + final values = []; + final keys = []; + + @override + Future write(AddressLabelKey key, String value) async { + values.add(value); + keys.add(key); + await onWrite?.call(key, value); + } +}