From 2f53f0e51e4832a4a789811da3106edcd2af7427 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 19:33:44 -0600 Subject: [PATCH 1/2] chore(coin-control): apply dart format to utxo_card.dart The file predates the current formatter and CI checks the formatting of every changed file. No behaviour change. --- lib/pages/coin_control/utxo_card.dart | 47 +++++++++++++-------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/lib/pages/coin_control/utxo_card.dart b/lib/pages/coin_control/utxo_card.dart index 624b41eee9..a576928dc9 100644 --- a/lib/pages/coin_control/utxo_card.dart +++ b/lib/pages/coin_control/utxo_card.dart @@ -94,8 +94,9 @@ class _UtxoCardState extends ConsumerState { focusElevation: 0, highlightElevation: 0, shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(Constants.size.circularBorderRadius), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), ), onPressed: widget.onPressed, child: child, @@ -124,45 +125,43 @@ class _UtxoCardState extends ConsumerState { ), child: UTXOStatusIcon( blocked: utxo.isBlocked, - status: _isConfirmed( - utxo, - currentHeight, - ref.watch( - pWallets.select( - (s) => s.getWallet( - widget.walletId, + status: + _isConfirmed( + utxo, + currentHeight, + ref.watch( + pWallets.select( + (s) => s.getWallet(widget.walletId), + ), ), - ), - ), - ) + ) ? UTXOStatusIconStatus.confirmed : UTXOStatusIconStatus.unconfirmed, - background: - Theme.of(context).extension()!.popupBG, + background: Theme.of( + context, + ).extension()!.popupBG, selected: _selected, width: 32, height: 32, ), ), - const SizedBox( - width: 10, - ), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - ref.watch(pAmountFormatter(coin)).format( + ref + .watch(pAmountFormatter(coin)) + .format( utxo.value.toAmountAsRaw( fractionDigits: coin.fractionDigits, ), ), style: STextStyles.w600_14(context), ), - const SizedBox( - height: 2, - ), + const SizedBox(height: 2), Row( children: [ Flexible( @@ -171,9 +170,9 @@ class _UtxoCardState extends ConsumerState { ? utxo.name : utxo.address ?? utxo.txid, style: STextStyles.w500_12(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), ), From d0e7acbb1caf6973e08f435460eb61ab3427c70d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 19:34:44 -0600 Subject: [PATCH 2/2] feat: show address labels in coin control UTXO cards Read the label through an auto-disposed riverpod family backed by a watched Isar query, so a card also picks up label edits made while the coin control list is on screen. closes #403 --- lib/pages/coin_control/utxo_card.dart | 25 ++++ .../wallet/address_label_provider.dart | 92 +++++++++++++ .../wallet/address_label_provider_test.dart | 130 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 lib/providers/wallet/address_label_provider.dart create mode 100644 test/providers/wallet/address_label_provider_test.dart diff --git a/lib/pages/coin_control/utxo_card.dart b/lib/pages/coin_control/utxo_card.dart index a576928dc9..15ce14ddf3 100644 --- a/lib/pages/coin_control/utxo_card.dart +++ b/lib/pages/coin_control/utxo_card.dart @@ -14,6 +14,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/isar_models.dart'; import '../../providers/global/wallets_provider.dart'; +import '../../providers/wallet/address_label_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -111,6 +112,15 @@ class _UtxoCardState extends ConsumerState { if (snapshot.hasData) { utxo = snapshot.data!; } + final addressLabel = utxo.address == null + ? null + : ref.watch( + pAddressLabel(( + walletId: widget.walletId, + address: utxo.address!, + )), + ); + return Row( children: [ ConditionalParent( @@ -178,6 +188,21 @@ class _UtxoCardState extends ConsumerState { ), ], ), + if (addressLabel != null && addressLabel.value.isNotEmpty) + Row( + children: [ + Flexible( + child: Text( + addressLabel.value, + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + ], + ), ], ), ), 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/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'); + } +}