diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 61f8ae5fbe..2269443c42 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( @@ -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..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 @@ -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( @@ -605,9 +605,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( @@ -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: [ @@ -909,7 +934,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 +943,9 @@ class _DesktopReceiveState extends ConsumerState { label: "Receive Slatepack", enabled: _slateToggleFlag, onPressed: _slateToggleFlag - ? (isEpiccash ? _onEpicReceiveSlatePressed : _onReceiveSlatePressed) + ? (isEpiccash + ? _onEpicReceiveSlatePressed + : _onReceiveSlatePressed) : null, ), ) 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); + } +}