From 219b42dc615fbaec443e79087c9493729fe4cd66 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 19:36:01 -0600 Subject: [PATCH 1/3] feat: address label editing to receive page closes #374 --- lib/pages/receive_view/receive_view.dart | 54 ++++++++++++++ .../sub_widgets/desktop_receive.dart | 72 +++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 61f8ae5fbe..c34305337d 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -17,6 +17,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar_community/isar.dart'; +import '../../db/isar/main_db.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/keys/view_only_wallet_data.dart'; import '../../notifications/show_flush_bar.dart'; @@ -46,6 +47,7 @@ import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; +import '../../widgets/custom_buttons/simple_edit_button.dart'; import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; @@ -792,6 +794,58 @@ class _ReceiveViewState extends ConsumerState { ), ), const SizedBox(height: 12), + Builder( + builder: (context) { + final label = MainDB.instance.getAddressLabelSync( + walletId, + address, + ); + final labelValue = label?.value ?? ""; + return RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + labelValue.isNotEmpty + ? labelValue + : "No label", + style: STextStyles.itemSubtitle(context), + ), + ), + SimpleEditButton( + editValue: labelValue, + editLabel: "label", + overrideTitle: "Edit label", + onValueChanged: (value) { + final existingLabel = + MainDB.instance.getAddressLabelSync( + walletId, + address, + ); + if (existingLabel != null) { + MainDB.instance.putAddressLabel( + existingLabel.copyWith(label: value), + ); + } else { + MainDB.instance.putAddressLabel( + AddressLabel( + walletId: walletId, + addressString: address, + value: value, + tags: null, + ), + ); + } + setState(() {}); + }, + ), + ], + ), + ); + }, + ), + 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..285c4ec159 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 @@ -18,6 +18,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar_community/isar.dart'; import 'package:tuple/tuple.dart'; +import '../../../../db/isar/main_db.dart'; import '../../../../models/isar/models/isar_models.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../notifications/show_flush_bar.dart'; @@ -51,6 +52,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart' import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/custom_buttons/simple_edit_button.dart'; import '../../../../widgets/custom_loading_overlay.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/primary_button.dart'; @@ -762,6 +764,76 @@ class _DesktopReceiveState extends ConsumerState { ), ), + const SizedBox(height: 12), + Builder( + builder: (context) { + final label = MainDB.instance.getAddressLabelSync( + walletId, + address, + ); + final labelValue = label?.value ?? ""; + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of( + context, + ).extension()!.backgroundAppBar, + width: 1, + ), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + labelValue.isNotEmpty ? labelValue : "No label", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ), + SimpleEditButton( + editValue: labelValue, + editLabel: "label", + overrideTitle: "Edit label", + onValueChanged: (value) { + final existingLabel = + MainDB.instance.getAddressLabelSync( + walletId, + address, + ); + if (existingLabel != null) { + MainDB.instance.putAddressLabel( + existingLabel.copyWith(label: value), + ); + } else { + MainDB.instance.putAddressLabel( + AddressLabel( + walletId: walletId, + addressString: address, + value: value, + tags: null, + ), + ); + } + setState(() {}); + }, + ), + ], + ), + ), + ); + }, + ), + if (canGen) const SizedBox(height: 20), if (canGen) From 6596a01c80a14da2a2cab0e152bc04b38ac8efcf Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 11:49:58 -0500 Subject: [PATCH 2/3] chore: dart format --- lib/pages/receive_view/receive_view.dart | 10 ++--- .../sub_widgets/desktop_receive.dart | 39 ++++++++++--------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index c34305337d..26ce3a1931 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -163,8 +163,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( @@ -818,11 +817,8 @@ class _ReceiveViewState extends ConsumerState { editLabel: "label", overrideTitle: "Edit label", onValueChanged: (value) { - final existingLabel = - MainDB.instance.getAddressLabelSync( - walletId, - address, - ); + final existingLabel = MainDB.instance + .getAddressLabelSync(walletId, address); if (existingLabel != null) { MainDB.instance.putAddressLabel( existingLabel.copyWith(label: value), 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 285c4ec159..5b47ea47fb 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 @@ -176,8 +176,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( @@ -607,9 +606,11 @@ class _DesktopReceiveState extends ConsumerState { ), ), ), - 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( @@ -791,13 +792,12 @@ class _DesktopReceiveState extends ConsumerState { Expanded( child: Text( labelValue.isNotEmpty ? labelValue : "No label", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), SimpleEditButton( @@ -805,11 +805,8 @@ class _DesktopReceiveState extends ConsumerState { editLabel: "label", overrideTitle: "Edit label", onValueChanged: (value) { - final existingLabel = - MainDB.instance.getAddressLabelSync( - walletId, - address, - ); + final existingLabel = MainDB.instance + .getAddressLabelSync(walletId, address); if (existingLabel != null) { MainDB.instance.putAddressLabel( existingLabel.copyWith(label: value), @@ -850,7 +847,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: [ @@ -981,7 +979,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( @@ -989,7 +988,9 @@ class _DesktopReceiveState extends ConsumerState { label: "Receive Slatepack", enabled: _slateToggleFlag, onPressed: _slateToggleFlag - ? (isEpiccash ? _onEpicReceiveSlatePressed : _onReceiveSlatePressed) + ? (isEpiccash + ? _onEpicReceiveSlatePressed + : _onReceiveSlatePressed) : null, ), ) From 89545661f2c284e5f69426de150b954b336ecaf0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 14:33:35 -0500 Subject: [PATCH 3/3] fix(receive): serialize address label edits --- lib/pages/receive_view/receive_view.dart | 56 +----- .../sub_widgets/desktop_receive.dart | 85 ++------ .../wallet/address_label_provider.dart | 71 +++++++ lib/widgets/address_label_editor.dart | 111 ++++++++++ .../address_label_editor_test.dart | 189 ++++++++++++++++++ 5 files changed, 398 insertions(+), 114 deletions(-) create mode 100644 lib/providers/wallet/address_label_provider.dart create mode 100644 lib/widgets/address_label_editor.dart create mode 100644 test/widget_tests/address_label_editor_test.dart diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 26ce3a1931..2269443c42 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -17,7 +17,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar_community/isar.dart'; -import '../../db/isar/main_db.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/keys/view_only_wallet_data.dart'; import '../../notifications/show_flush_bar.dart'; @@ -43,11 +42,11 @@ 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'; import '../../widgets/custom_buttons/blue_text_button.dart'; -import '../../widgets/custom_buttons/simple_edit_button.dart'; import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; @@ -793,53 +792,12 @@ class _ReceiveViewState extends ConsumerState { ), ), const SizedBox(height: 12), - Builder( - builder: (context) { - final label = MainDB.instance.getAddressLabelSync( - walletId, - address, - ); - final labelValue = label?.value ?? ""; - return RoundedWhiteContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - labelValue.isNotEmpty - ? labelValue - : "No label", - style: STextStyles.itemSubtitle(context), - ), - ), - SimpleEditButton( - editValue: labelValue, - editLabel: "label", - overrideTitle: "Edit label", - onValueChanged: (value) { - final existingLabel = MainDB.instance - .getAddressLabelSync(walletId, address); - if (existingLabel != null) { - MainDB.instance.putAddressLabel( - existingLabel.copyWith(label: value), - ); - } else { - MainDB.instance.putAddressLabel( - AddressLabel( - walletId: walletId, - addressString: address, - value: value, - tags: null, - ), - ); - } - setState(() {}); - }, - ), - ], - ), - ); - }, + RoundedWhiteContainer( + child: AddressLabelEditor( + walletId: walletId, + address: address, + isDesktop: false, + ), ), const SizedBox(height: 12), PrimaryButton( 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 5b47ea47fb..70ce1b67fb 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 @@ -18,7 +18,6 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar_community/isar.dart'; import 'package:tuple/tuple.dart'; -import '../../../../db/isar/main_db.dart'; import '../../../../models/isar/models/isar_models.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../notifications/show_flush_bar.dart'; @@ -50,9 +49,9 @@ 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_buttons/simple_edit_button.dart'; import '../../../../widgets/custom_loading_overlay.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/primary_button.dart'; @@ -766,69 +765,25 @@ class _DesktopReceiveState extends ConsumerState { ), const SizedBox(height: 12), - Builder( - builder: (context) { - final label = MainDB.instance.getAddressLabelSync( - walletId, - address, - ); - final labelValue = label?.value ?? ""; - return Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).extension()!.backgroundAppBar, - width: 1, - ), - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - child: RoundedWhiteContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - labelValue.isNotEmpty ? labelValue : "No label", - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of( - context, - ).extension()!.textDark, - ), - ), - ), - SimpleEditButton( - editValue: labelValue, - editLabel: "label", - overrideTitle: "Edit label", - onValueChanged: (value) { - final existingLabel = MainDB.instance - .getAddressLabelSync(walletId, address); - if (existingLabel != null) { - MainDB.instance.putAddressLabel( - existingLabel.copyWith(label: value), - ); - } else { - MainDB.instance.putAddressLabel( - AddressLabel( - walletId: walletId, - addressString: address, - value: value, - tags: null, - ), - ); - } - setState(() {}); - }, - ), - ], - ), - ), - ); - }, + 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), diff --git a/lib/providers/wallet/address_label_provider.dart b/lib/providers/wallet/address_label_provider.dart new file mode 100644 index 0000000000..57223ad6e7 --- /dev/null +++ b/lib/providers/wallet/address_label_provider.dart @@ -0,0 +1,71 @@ +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; + + 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 { + _AddressLabelWatcher(AddressLabelStore store, AddressLabelKey key) + : _value = store.find(key) { + _subscription = store.watch(key).listen((labels) { + _value = labels.firstOrNull; + notifyListeners(); + }); + } + + 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); + }); + +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..a63023da6a --- /dev/null +++ b/lib/widgets/address_label_editor.dart @@ -0,0 +1,111 @@ +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(); + + void _queueWrite(String value) { + final key = (walletId: widget.walletId, address: widget.address); + final writer = ref.read(addressLabelWriterProvider); + _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 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: _queueWrite, + ), + ], + ); + } +} 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..29972418d8 --- /dev/null +++ b/test/widget_tests/address_label_editor_test.dart @@ -0,0 +1,189 @@ +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)); + }); +} + +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, +}) => 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: _key.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 = []; + + @override + Future write(AddressLabelKey key, String value) async { + values.add(value); + await onWrite?.call(key, value); + } +}