From aeb843f1ae30e33d733dec7888e2ba8e72cab439 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:24:51 -0500 Subject: [PATCH 1/4] feat(wallet): add address label provider Watch an address's label row through an auto-disposed riverpod family backed by a watched Isar query, so widgets pick up label edits made elsewhere while they are on screen. --- .../wallet/address_label_provider.dart | 92 +++++++++++++ .../wallet/address_label_provider_test.dart | 130 ++++++++++++++++++ 2 files changed, 222 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/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'); + } +} From c45eb253274a68b6245f5739ca35fa4cf0ed2664 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 19:33:44 -0600 Subject: [PATCH 2/4] style(coin-control): dart format utxo_card and utxo_row --- lib/pages/coin_control/utxo_card.dart | 41 +++++---- .../coin_control/utxo_row.dart | 84 ++++++++----------- 2 files changed, 56 insertions(+), 69 deletions(-) diff --git a/lib/pages/coin_control/utxo_card.dart b/lib/pages/coin_control/utxo_card.dart index 624b41eee9..fae43bcfb0 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( diff --git a/lib/pages_desktop_specific/coin_control/utxo_row.dart b/lib/pages_desktop_specific/coin_control/utxo_row.dart index e27d970fc5..3b0c439494 100644 --- a/lib/pages_desktop_specific/coin_control/utxo_row.dart +++ b/lib/pages_desktop_specific/coin_control/utxo_row.dart @@ -78,19 +78,17 @@ class _UtxoRowState extends ConsumerState { void _details() async { await showDialog( context: context, - builder: - (context) => - UtxoDetailsView(utxoId: utxo.id, walletId: widget.walletId), + builder: (context) => + UtxoDetailsView(utxoId: utxo.id, walletId: widget.walletId), ); } @override void initState() { - utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(widget.data.utxoId) - .findFirstSync()!; + utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(widget.data.utxoId) + .findFirstSync()!; stream = MainDB.instance.watchUTXO(id: utxo.id); super.initState(); @@ -110,21 +108,13 @@ class _UtxoRowState extends ConsumerState { } return RoundedContainer( - borderColor: - widget.compact && widget.compactWithBorder - ? Theme.of( - context, - ).extension()!.textFieldDefaultBG - : null, + borderColor: widget.compact && widget.compactWithBorder + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, color: Theme.of(context).extension()!.popupBG, - boxShadow: - widget.data.selected && widget.raiseOnSelected - ? [ - Theme.of( - context, - ).extension()!.standardBoxShadow, - ] - : null, + boxShadow: widget.data.selected && widget.raiseOnSelected + ? [Theme.of(context).extension()!.standardBoxShadow] + : null, child: Row( children: [ if (!(widget.compact && utxo.isBlocked)) @@ -143,21 +133,19 @@ class _UtxoRowState extends ConsumerState { blocked: utxo.isBlocked, status: (coin is Namecoin - ? (ref.watch(pWallets).getWallet(widget.walletId) - as NamecoinWallet) - .checkUtxoConfirmed( - utxo, - ref.watch( - pWalletChainHeight(widget.walletId), - ), - ) - : utxo.isConfirmed( - ref.watch(pWalletChainHeight(widget.walletId)), - coin.minConfirms, - coin.minCoinbaseConfirms, - )) - ? UTXOStatusIconStatus.confirmed - : UTXOStatusIconStatus.unconfirmed, + ? (ref.watch(pWallets).getWallet(widget.walletId) + as NamecoinWallet) + .checkUtxoConfirmed( + utxo, + ref.watch(pWalletChainHeight(widget.walletId)), + ) + : utxo.isConfirmed( + ref.watch(pWalletChainHeight(widget.walletId)), + coin.minConfirms, + coin.minCoinbaseConfirms, + )) + ? UTXOStatusIconStatus.confirmed + : UTXOStatusIconStatus.unconfirmed, background: Theme.of(context).extension()!.popupBG, selected: false, width: 32, @@ -207,13 +195,13 @@ class _UtxoRowState extends ConsumerState { utxo.name.isNotEmpty ? utxo.name : utxo.address ?? utxo.txid, - textAlign: - widget.compact ? TextAlign.left : TextAlign.center, + textAlign: widget.compact + ? TextAlign.left + : TextAlign.center, style: STextStyles.w500_12(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), ), @@ -222,11 +210,11 @@ class _UtxoRowState extends ConsumerState { widget.compact ? CustomTextButton(text: "Details", onTap: _details) : SecondaryButton( - width: 120, - buttonHeight: ButtonHeight.xs, - label: "Details", - onPressed: _details, - ), + width: 120, + buttonHeight: ButtonHeight.xs, + label: "Details", + onPressed: _details, + ), ], ), ); From 282742cddf105eb41c10f316386a3401fd61b8c6 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 19:34:44 -0600 Subject: [PATCH 3/4] feat(coin-control): show address labels in UTXO cards Closes #403 --- lib/pages/coin_control/utxo_card.dart | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/pages/coin_control/utxo_card.dart b/lib/pages/coin_control/utxo_card.dart index fae43bcfb0..a4dbca5540 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, + ), + ), + ), + ], + ), ], ), ), From cccdcdcb1d3ea2f7ad2d561094d81679bb03f313 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:25:31 -0500 Subject: [PATCH 4/4] feat(coin-control): show address labels in desktop UTXO rows --- .../coin_control/utxo_row.dart | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/lib/pages_desktop_specific/coin_control/utxo_row.dart b/lib/pages_desktop_specific/coin_control/utxo_row.dart index 3b0c439494..080b4f172a 100644 --- a/lib/pages_desktop_specific/coin_control/utxo_row.dart +++ b/lib/pages_desktop_specific/coin_control/utxo_row.dart @@ -16,6 +16,7 @@ import '../../db/isar/main_db.dart'; import '../../models/isar/models/isar_models.dart'; import '../../pages/coin_control/utxo_details_view.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'; @@ -106,6 +107,14 @@ class _UtxoRowState extends ConsumerState { if (snapshot.hasData) { utxo = snapshot.data!; } + final addressLabel = utxo.address == null + ? null + : ref.watch( + pAddressLabel(( + walletId: widget.walletId, + address: utxo.address!, + )), + ); return RoundedContainer( borderColor: widget.compact && widget.compactWithBorder @@ -191,18 +200,38 @@ class _UtxoRowState extends ConsumerState { ], ); }, - child: Text( - utxo.name.isNotEmpty - ? utxo.name - : utxo.address ?? utxo.txid, - textAlign: widget.compact - ? TextAlign.left - : TextAlign.center, - style: STextStyles.w500_12(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, - ), + child: Column( + crossAxisAlignment: widget.compact + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + utxo.name.isNotEmpty + ? utxo.name + : utxo.address ?? utxo.txid, + textAlign: widget.compact + ? TextAlign.left + : TextAlign.center, + style: STextStyles.w500_12(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + if (addressLabel != null && addressLabel.value.isNotEmpty) + Text( + addressLabel.value, + textAlign: widget.compact + ? TextAlign.left + : TextAlign.center, + style: STextStyles.w500_12(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + ], ), ), ),