Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 48 additions & 24 deletions lib/pages/coin_control/utxo_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -94,8 +95,9 @@ class _UtxoCardState extends ConsumerState<UtxoCard> {
focusElevation: 0,
highlightElevation: 0,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(Constants.size.circularBorderRadius),
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
onPressed: widget.onPressed,
child: child,
Expand All @@ -110,6 +112,15 @@ class _UtxoCardState extends ConsumerState<UtxoCard> {
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(
Expand All @@ -124,45 +135,43 @@ class _UtxoCardState extends ConsumerState<UtxoCard> {
),
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<StackColors>()!.popupBG,
background: Theme.of(
context,
).extension<StackColors>()!.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(
Expand All @@ -171,14 +180,29 @@ class _UtxoCardState extends ConsumerState<UtxoCard> {
? utxo.name
: utxo.address ?? utxo.txid,
style: STextStyles.w500_12(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textSubtitle1,
color: Theme.of(
context,
).extension<StackColors>()!.textSubtitle1,
),
),
),
],
),
if (addressLabel != null && addressLabel.value.isNotEmpty)
Row(
children: [
Flexible(
child: Text(
addressLabel.value,
style: STextStyles.w500_12(context).copyWith(
color: Theme.of(
context,
).extension<StackColors>()!.textSubtitle1,
),
),
),
],
),
],
),
),
Expand Down
92 changes: 92 additions & 0 deletions lib/providers/wallet/address_label_provider.dart
Original file line number Diff line number Diff line change
@@ -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<List<AddressLabel>> 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<AddressLabel, AddressLabel, QAfterWhereClause> _query(
AddressLabelKey key,
) => isar.addressLabels.where().addressStringWalletIdEqualTo(
key.address,
key.walletId,
);

@override
AddressLabel? find(AddressLabelKey key) => _query(key).findFirstSync();

@override
Stream<List<AddressLabel>> watch(AddressLabelKey key) =>
_query(key).watch(fireImmediately: true);
}

final addressLabelStoreProvider = Provider<AddressLabelStore>((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<List<AddressLabel>> _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<AddressLabel?, AddressLabelKey>(
(ref, key) => ref.watch(_addressLabelWatcherProvider(key)).value,
);
130 changes: 130 additions & 0 deletions test/providers/wallet/address_label_provider_test.dart
Original file line number Diff line number Diff line change
@@ -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<AddressLabel?>(
pAddressLabel(_key),
(_, __) {},
);
addTearDown(sub.close);

store.emitError(StateError('db closed'));
await Future<void>.delayed(Duration.zero);
afterError = container.read(pAddressLabel(_key));

store.emit(_label('Updated label'));
await Future<void>.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<List<AddressLabel>>.broadcast();
AddressLabel? value;

@override
AddressLabel? find(AddressLabelKey key) => value;

@override
Stream<List<AddressLabel>> 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');
}
}
Loading