From b74b4d6327160c39c5c33ee2b592dd8fd31e5958 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 17:21:10 -0500 Subject: [PATCH 1/2] feat: add address tag data layer and editor dialog Normalized tag helpers, a WalletAddressFilter-keyed pair of watched providers and the shared address query, plus a bounded tag chip, a filter strip and a capped tag editor whose save is awaited and retryable. --- .../receive_view/addresses/address_tag.dart | 23 +- .../addresses/address_tag_data.dart | 194 ++++++++ .../addresses/address_tag_editor_dialog.dart | 415 ++++++++++++++++++ .../addresses/address_tag_filter.dart | 109 +++++ .../receive_view/address_tag_data_test.dart | 224 ++++++++++ .../address_tag_editor_dialog_test.dart | 202 +++++++++ 6 files changed, 1159 insertions(+), 8 deletions(-) create mode 100644 lib/pages/receive_view/addresses/address_tag_data.dart create mode 100644 lib/pages/receive_view/addresses/address_tag_editor_dialog.dart create mode 100644 lib/pages/receive_view/addresses/address_tag_filter.dart create mode 100644 test/pages/receive_view/address_tag_data_test.dart create mode 100644 test/pages/receive_view/address_tag_editor_dialog_test.dart diff --git a/lib/pages/receive_view/addresses/address_tag.dart b/lib/pages/receive_view/addresses/address_tag.dart index c6a1efcd44..256db9f896 100644 --- a/lib/pages/receive_view/addresses/address_tag.dart +++ b/lib/pages/receive_view/addresses/address_tag.dart @@ -24,15 +24,22 @@ class AddressTag extends StatelessWidget { Widget build(BuildContext context) { return RoundedContainer( radiusMultiplier: 0.5, - padding: const EdgeInsets.symmetric( - vertical: 5, - horizontal: 7, - ), + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 7), color: Theme.of(context).extension()!.buttonBackPrimary, - child: Text( - tag.capitalize(), - style: STextStyles.w500_14(context).copyWith( - color: Theme.of(context).extension()!.buttonTextPrimary, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Tooltip( + message: tag, + child: Text( + tag.capitalize(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + ), ), ), ); diff --git a/lib/pages/receive_view/addresses/address_tag_data.dart b/lib/pages/receive_view/addresses/address_tag_data.dart new file mode 100644 index 0000000000..fe7dbcb7d2 --- /dev/null +++ b/lib/pages/receive_view/addresses/address_tag_data.dart @@ -0,0 +1,194 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + */ + +import 'package:async/async.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../db/isar/main_db.dart'; +import '../../../models/isar/models/isar_models.dart'; +import '../../../providers/db/main_db_provider.dart'; + +const maxAddressTagCount = 12; +const maxAddressTagLength = 32; + +/// Cc/Cf ranges: `trim()` only removes whitespace, so without this a tag can +/// be non-empty yet render as nothing. +final _invisibleTagChars = RegExp( + r"[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f" + r"\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff\ufff9-\ufffb]", +); + +String normalizeAddressTag(String value) => + value.replaceAll(_invisibleTagChars, "").trim().toLowerCase(); + +List distinctAddressTags(Iterable labels) { + final tags = {}; + for (final label in labels) { + for (final tag in label.tags ?? const []) { + final normalized = normalizeAddressTag(tag); + if (normalized.isNotEmpty) { + tags.add(normalized); + } + } + } + return tags.toList()..sort(); +} + +String? reconcileSelectedAddressTag(String? selectedTag, List tags) { + final normalized = selectedTag == null + ? null + : normalizeAddressTag(selectedTag); + return normalized != null && tags.contains(normalized) ? normalized : null; +} + +@immutable +class WalletAddressFilter { + const WalletAddressFilter({ + required this.walletId, + this.searchTerm = "", + this.tag, + }); + + final String walletId; + final String searchTerm; + final String? tag; + + @override + bool operator ==(Object other) => + other is WalletAddressFilter && + other.walletId == walletId && + other.searchTerm == searchTerm && + other.tag == tag; + + @override + int get hashCode => Object.hash(walletId, searchTerm, tag); +} + +final walletAddressTagsProvider = StreamProvider.autoDispose + .family, String>((ref, walletId) { + final db = ref.watch(mainDBProvider); + return db + .getAddressLabels(walletId) + .watch(fireImmediately: true) + .map(distinctAddressTags); + }); + +final filteredWalletAddressIdsProvider = StreamProvider.autoDispose + .family, WalletAddressFilter>((ref, filter) { + final db = ref.watch(mainDBProvider); + final changes = StreamGroup.merge([ + db.getAddresses(filter.walletId).watchLazy(fireImmediately: true), + db.getAddressLabels(filter.walletId).watchLazy(fireImmediately: true), + ]); + return changes.asyncMap((_) => findFilteredWalletAddressIds(db, filter)); + }); + +Future> findFilteredWalletAddressIds( + MainDB db, + WalletAddressFilter filter, +) async { + final term = filter.searchTerm.trim(); + final tag = filter.tag == null ? null : normalizeAddressTag(filter.tag!); + + if (term.isEmpty && tag == null) { + return db + .getAddresses(filter.walletId) + .filter() + .group(_supportedAddressSubtypes) + .and() + .not() + .typeEqualTo(AddressType.nonWallet) + .and() + .group(_supportedFrostAddresses) + .sortByDerivationIndex() + .idProperty() + .findAll(); + } + + final candidates = await db + .getAddressLabels(filter.walletId) + .filter() + .group( + (q) => tag == null + ? q.addressStringIsNotEmpty() + : q.tagsIsNotNull().and().tagsIsNotEmpty(), + ) + .and() + .group( + (q) => term.isEmpty + ? q.addressStringIsNotEmpty() + : q + .valueContains(term, caseSensitive: false) + .or() + .addressStringContains(term, caseSensitive: false) + .or() + .group( + (q) => q.tagsIsNotNull().and().tagsElementContains( + term, + caseSensitive: false, + ), + ), + ) + .findAll(); + + // Chips are built from normalized tags, so the tag match must be normalized + // too; Isar compares the raw stored string and would miss an untrimmed tag. + final labels = tag == null + ? candidates + : candidates + .where( + (label) => (label.tags ?? const []).any( + (t) => normalizeAddressTag(t) == tag, + ), + ) + .toList(); + + if (labels.isEmpty) { + return []; + } + + return db + .getAddresses(filter.walletId) + .filter() + .anyOf( + labels, + (q, label) => q.valueEqualTo(label.addressString), + ) + .group(_supportedAddressSubtypes) + .and() + .not() + .typeEqualTo(AddressType.nonWallet) + .and() + .group(_supportedFrostAddresses) + .sortByDerivationIndex() + .idProperty() + .findAll(); +} + +QueryBuilder _supportedAddressSubtypes( + QueryBuilder q, +) => q + .subTypeEqualTo(AddressSubType.change) + .or() + .subTypeEqualTo(AddressSubType.receiving) + .or() + .subTypeEqualTo(AddressSubType.paynymReceive) + .or() + .subTypeEqualTo(AddressSubType.paynymNotification); + +QueryBuilder _supportedFrostAddresses( + QueryBuilder q, +) => q + .group( + (q) => q.typeEqualTo(AddressType.frostMS).and().zSafeFrostEqualTo(true), + ) + .or() + .not() + .typeEqualTo(AddressType.frostMS); diff --git a/lib/pages/receive_view/addresses/address_tag_editor_dialog.dart b/lib/pages/receive_view/addresses/address_tag_editor_dialog.dart new file mode 100644 index 0000000000..8669642c85 --- /dev/null +++ b/lib/pages/receive_view/addresses/address_tag_editor_dialog.dart @@ -0,0 +1,415 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../widgets/desktop/desktop_dialog.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../../widgets/stack_dialog.dart'; +import '../../../widgets/stack_text_field.dart'; +import 'address_tag_data.dart'; + +class AddressTagEditorDialog extends StatefulWidget { + const AddressTagEditorDialog({ + super.key, + required this.tags, + required this.onSave, + }); + + final List tags; + final Future Function(List) onSave; + + @override + State createState() => _AddressTagEditorDialogState(); +} + +class _AddressTagEditorDialogState extends State { + static const _defaultSuggestions = [ + "personal", + "business", + "mining", + "exchange", + "donation", + "savings", + ]; + + late final List _tags; + late final TextEditingController _controller; + late final FocusNode _focusNode; + bool _saving = false; + String? _saveError; + + @override + void initState() { + super.initState(); + _tags = List.from(widget.tags); + _controller = TextEditingController()..addListener(_onInputChanged); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _controller + ..removeListener(_onInputChanged) + ..dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onInputChanged() => setState(() {}); + + bool get _atLimit => _tags.length >= maxAddressTagCount; + + String get _normalizedInput => normalizeAddressTag(_controller.text); + + // The input formatter caps characters while _addTag caps code units, so the + // button has to use _addTag's rule or it would enable a no-op. + bool get _canAdd => + !_atLimit && + _normalizedInput.isNotEmpty && + _normalizedInput.length <= maxAddressTagLength && + !_tags.any((tag) => normalizeAddressTag(tag) == _normalizedInput); + + bool get _inputTooLong => _normalizedInput.length > maxAddressTagLength; + + void _addTag(String value) { + final tag = normalizeAddressTag(value); + if (_tags.length >= maxAddressTagCount || + tag.isEmpty || + tag.length > maxAddressTagLength || + _tags.any((existing) => normalizeAddressTag(existing) == tag)) { + return; + } + setState(() { + _tags.add(tag); + _saveError = null; + }); + _controller.clear(); + } + + void _removeTag(String tag) { + setState(() { + _tags.remove(tag); + _saveError = null; + }); + } + + List get _availableSuggestions => _atLimit + ? const [] + : _defaultSuggestions + .where( + (suggestion) => + !_tags.any((tag) => normalizeAddressTag(tag) == suggestion), + ) + .toList(); + + Future _save() async { + if (_saving) { + return; + } + setState(() { + _saving = true; + _saveError = null; + }); + try { + await widget.onSave(List.unmodifiable(_tags)); + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e, s) { + Logging.instance.e( + "Failed to save address tags", + error: e, + stackTrace: s, + ); + if (mounted) { + setState(() { + _saving = false; + _saveError = "Couldn't save tags. Try again."; + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (Util.isDesktop) { + final height = (MediaQuery.sizeOf(context).height - 48) + .clamp(320.0, 560.0) + .toDouble(); + return DesktopDialog( + maxWidth: 500, + maxHeight: height, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Edit tags", + style: STextStyles.desktopH3(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: _saving + ? () {} + : () => Navigator.of(context).pop(), + ), + ], + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: _buildContent(context), + ), + ), + _buildActions(context, const EdgeInsets.all(32), 16), + ], + ), + ); + } + + return StackDialogBase( + keyboardPaddingAmount: MediaQuery.of(context).viewInsets.bottom, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Edit tags", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 16), + _buildContent(context), + _buildActions(context, const EdgeInsets.only(top: 20), 8), + ], + ), + ); + } + + Widget _buildActions( + BuildContext context, + EdgeInsets padding, + double spacing, + ) { + return Padding( + padding: padding, + child: LayoutBuilder( + builder: (context, constraints) { + final cancel = SecondaryButton( + label: "Cancel", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: _saving ? null : () => Navigator.of(context).pop(), + ); + final save = PrimaryButton( + label: _saving ? "Saving..." : "Save", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + enabled: !_saving, + onPressed: _saving ? null : _save, + ); + if (constraints.maxWidth < 320) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + cancel, + SizedBox(height: spacing), + save, + ], + ); + } + return Row( + children: [ + Expanded(child: cancel), + SizedBox(width: spacing), + Expanded(child: save), + ], + ); + }, + ), + ); + } + + Widget _buildContent(BuildContext context) { + final colors = Theme.of(context).extension()!; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_tags.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: _tags.map((tag) { + return RoundedContainer( + radiusMultiplier: 0.5, + padding: const EdgeInsets.only(left: 8), + color: colors.buttonBackPrimary, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Tooltip( + message: tag, + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: STextStyles.w500_14( + context, + ).copyWith(color: colors.buttonTextPrimary), + ), + ), + ), + IconButton( + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + padding: EdgeInsets.zero, + tooltip: "Remove $tag tag", + onPressed: _saving ? null : () => _removeTag(tag), + icon: Icon( + Icons.close, + size: 18, + color: colors.buttonTextPrimary, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + if (_tags.isNotEmpty) const SizedBox(height: 12), + _buildTagInput(context, colors), + const SizedBox(height: 8), + Text( + _atLimit + ? "Maximum of $maxAddressTagCount tags reached" + : _inputTooLong + ? "Tag is too long" + : "${_tags.length} of $maxAddressTagCount tags", + style: STextStyles.w500_12( + context, + ).copyWith(color: colors.textSubtitle2), + ), + if (_saveError != null) const SizedBox(height: 8), + if (_saveError != null) + Text( + _saveError!, + key: const Key("addressTagSaveError"), + style: STextStyles.w500_12( + context, + ).copyWith(color: colors.snackBarTextError), + ), + if (_availableSuggestions.isNotEmpty) const SizedBox(height: 12), + if (_availableSuggestions.isNotEmpty) + Text( + "Suggestions", + style: STextStyles.itemSubtitle( + context, + ).copyWith(color: colors.textSubtitle1), + ), + if (_availableSuggestions.isNotEmpty) const SizedBox(height: 8), + if (_availableSuggestions.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: _availableSuggestions.map((suggestion) { + return RoundedContainer( + radiusMultiplier: 0.5, + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 7), + color: colors.buttonBackSecondary, + onPressed: _saving ? null : () => _addTag(suggestion), + child: Text( + suggestion, + style: STextStyles.w500_14( + context, + ).copyWith(color: colors.buttonTextSecondary), + ), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildTagInput(BuildContext context, StackColors colors) { + final field = ClipRRect( + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), + child: TextField( + autocorrect: false, + enableSuggestions: false, + enabled: !_saving && !_atLimit, + controller: _controller, + focusNode: _focusNode, + inputFormatters: [ + LengthLimitingTextInputFormatter(maxAddressTagLength), + ], + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: colors.textFieldActiveText, height: 1.8) + : STextStyles.field(context), + decoration: standardInputDecoration( + "Add tag", + _focusNode, + context, + desktopMed: Util.isDesktop, + ), + onSubmitted: (value) { + _addTag(value); + _focusNode.requestFocus(); + }, + ), + ); + final add = PrimaryButton( + width: 96, + label: "Add", + enabled: _canAdd && !_saving, + onPressed: _canAdd && !_saving + ? () { + _addTag(_controller.text); + _focusNode.requestFocus(); + } + : null, + ); + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 300) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + field, + const SizedBox(height: 8), + Align(alignment: Alignment.centerRight, child: add), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: field), + const SizedBox(width: 8), + add, + ], + ); + }, + ); + } +} diff --git a/lib/pages/receive_view/addresses/address_tag_filter.dart b/lib/pages/receive_view/addresses/address_tag_filter.dart new file mode 100644 index 0000000000..c858f9a0d8 --- /dev/null +++ b/lib/pages/receive_view/addresses/address_tag_filter.dart @@ -0,0 +1,109 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/rounded_container.dart'; + +/// A horizontal row of selectable tag chips used to filter the address list by +/// tag. An "All" chip clears the filter. The currently selected tag is +/// highlighted. Passing a null [selectedTag] means no filter (show all). +class AddressTagFilter extends StatelessWidget { + const AddressTagFilter({ + super.key, + required this.tags, + required this.selectedTag, + required this.onSelected, + }); + + /// All distinct tags available across the wallet's addresses. + final List tags; + + /// The currently selected tag, or null when "All" is selected. + final String? selectedTag; + + /// Called with the selected tag, or null when "All" is selected. + final void Function(String?) onSelected; + + @override + Widget build(BuildContext context) { + if (tags.isEmpty) { + return const SizedBox.shrink(); + } + + final entries = [null, ...tags]; + + return SizedBox( + height: 44, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: entries.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, index) { + final tag = entries[index]; + final selected = tag == null + ? selectedTag == null + : selectedTag == tag; + + return _FilterChip( + label: tag == null ? "All" : tag.capitalize(), + selected: selected, + onPressed: () => onSelected(tag), + ); + }, + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + const _FilterChip({ + required this.label, + required this.selected, + required this.onPressed, + }); + + final String label; + final bool selected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + + return RoundedContainer( + radiusMultiplier: 0.5, + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 12), + color: selected ? colors.buttonBackPrimary : colors.buttonBackSecondary, + onPressed: onPressed, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Tooltip( + message: label, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: STextStyles.w500_14(context).copyWith( + color: selected + ? colors.buttonTextPrimary + : colors.buttonTextSecondary, + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/pages/receive_view/address_tag_data_test.dart b/test/pages/receive_view/address_tag_data_test.dart new file mode 100644 index 0000000000..3e608d8812 --- /dev/null +++ b/test/pages/receive_view/address_tag_data_test.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar_community/isar.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/pages/receive_view/addresses/address_tag_data.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; + +void main() { + const walletId = "wallet-1"; + late Directory tempDir; + late Isar isar; + final db = MainDB.instance; + + Address address({ + required String value, + required int index, + String wallet = walletId, + AddressType type = AddressType.p2wpkh, + AddressSubType subType = AddressSubType.receiving, + bool? zSafeFrost, + }) => Address( + walletId: wallet, + value: value, + publicKey: const [], + derivationIndex: index, + derivationPath: null, + type: type, + subType: subType, + zSafeFrost: zSafeFrost, + ); + + AddressLabel label({ + required String address, + required List? tags, + String value = "", + String wallet = walletId, + }) => AddressLabel( + walletId: wallet, + addressString: address, + value: value, + tags: tags, + ); + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp("stack-tag-test-"); + isar = await Isar.open( + [AddressSchema, AddressLabelSchema, TransactionSchema], + directory: tempDir.path, + name: "address_tag_test", + ); + await db.initMainDB(mock: isar); + }); + + setUp(() async { + await isar.writeTxn(() async { + await isar.addresses.clear(); + await isar.addressLabels.clear(); + }); + }); + + tearDownAll(() async { + await isar.close(deleteFromDisk: true); + await tempDir.delete(recursive: true); + }); + + test("normalizes, deduplicates, and reconciles tags", () { + final tags = distinctAddressTags([ + label(address: "a", tags: [" Work ", "personal"]), + label(address: "b", tags: ["work", ""]), + ]); + + expect(tags, ["personal", "work"]); + expect(reconcileSelectedAddressTag(" WORK ", tags), "work"); + expect(reconcileSelectedAddressTag("removed", tags), isNull); + }); + + test("strips invisible characters when normalizing", () { + expect(normalizeAddressTag("\u200b"), isEmpty); + expect(normalizeAddressTag("\u202eevil"), "evil"); + expect( + distinctAddressTags([ + label(address: "a", tags: ["\u200bwork"]), + label(address: "b", tags: ["work", "\u200b"]), + ]), + ["work"], + ); + }); + + test("filters by normalized tag whatever form is stored", () async { + await isar.writeTxn(() async { + await isar.addresses.putAll([ + address(value: "padded", index: 0), + address(value: "uppercase", index: 1), + ]); + await isar.addressLabels.putAll([ + label(address: "padded", tags: [" Savings "]), + label(address: "uppercase", tags: ["Savings"]), + ]); + }); + + final chips = distinctAddressTags( + await db.getAddressLabels(walletId).findAll(), + ); + expect(chips, ["savings"]); + + // A chip the strip offers must never filter to nothing. + final ids = await findFilteredWalletAddressIds( + db, + WalletAddressFilter(walletId: walletId, tag: chips.single), + ); + expect(ids, hasLength(2)); + }); + + test("filters supported wallet addresses asynchronously", () async { + await isar.writeTxn(() async { + await isar.addresses.putAll([ + address(value: "receiving", index: 2), + address(value: "change", index: 1, subType: AddressSubType.change), + address( + value: "unsafe-frost", + index: 3, + type: AddressType.frostMS, + zSafeFrost: false, + ), + address(value: "external", index: 4, type: AddressType.nonWallet), + address(value: "other-wallet", index: 0, wallet: "wallet-2"), + ]); + await isar.addressLabels.putAll([ + label(address: "receiving", tags: ["income"], value: "Salary"), + label(address: "change", tags: ["private"]), + label(address: "other-wallet", tags: ["income"], wallet: "wallet-2"), + ]); + }); + + final all = await findFilteredWalletAddressIds( + db, + const WalletAddressFilter(walletId: walletId), + ); + final income = await findFilteredWalletAddressIds( + db, + const WalletAddressFilter(walletId: walletId, tag: "INCOME"), + ); + final salary = await findFilteredWalletAddressIds( + db, + const WalletAddressFilter(walletId: walletId, searchTerm: "salary"), + ); + + expect( + await Future.wait(all.map((id) => isar.addresses.get(id))), + hasLength(2), + ); + expect((await isar.addresses.get(all.first))!.value, "change"); + expect((await isar.addresses.get(income.single))!.value, "receiving"); + expect((await isar.addresses.get(salary.single))!.value, "receiving"); + }); + + test("tag provider reacts to label changes", () async { + final first = label(address: "a", tags: ["one"]); + await db.putAddressLabel(first); + final container = ProviderContainer( + overrides: [mainDBProvider.overrideWithValue(db)], + ); + addTearDown(container.dispose); + + final initial = Completer>(); + final updated = Completer>(); + final subscription = container.listen( + walletAddressTagsProvider(walletId), + (_, value) => value.whenData((tags) { + if (tags.contains("two") && !updated.isCompleted) { + updated.complete(tags); + } else if (!initial.isCompleted) { + initial.complete(tags); + } + }), + fireImmediately: true, + ); + addTearDown(subscription.close); + expect(await initial.future, ["one"]); + + await db.putAddressLabel(first.copyWith(tags: ["one", "two"])); + expect(await updated.future, ["one", "two"]); + }); + + test( + "filtered address provider reacts when a selected tag is removed", + () async { + final taggedAddress = address(value: "tagged", index: 0); + final taggedLabel = label(address: "tagged", tags: ["selected"]); + await isar.writeTxn(() async { + await isar.addresses.put(taggedAddress); + await isar.addressLabels.put(taggedLabel); + }); + final container = ProviderContainer( + overrides: [mainDBProvider.overrideWithValue(db)], + ); + addTearDown(container.dispose); + final initial = Completer>(); + final removed = Completer>(); + final subscription = container.listen( + filteredWalletAddressIdsProvider( + const WalletAddressFilter(walletId: walletId, tag: "selected"), + ), + (_, value) => value.whenData((ids) { + if (ids.isEmpty && initial.isCompleted && !removed.isCompleted) { + removed.complete(ids); + } else if (ids.isNotEmpty && !initial.isCompleted) { + initial.complete(ids); + } + }), + fireImmediately: true, + ); + addTearDown(subscription.close); + + expect(await initial.future, [taggedAddress.id]); + await db.putAddressLabel(taggedLabel.copyWith(tags: [])); + expect(await removed.future, isEmpty); + }, + ); +} diff --git a/test/pages/receive_view/address_tag_editor_dialog_test.dart b/test/pages/receive_view/address_tag_editor_dialog_test.dart new file mode 100644 index 0000000000..515f19d1e9 --- /dev/null +++ b/test/pages/receive_view/address_tag_editor_dialog_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/receive_view/addresses/address_tag_data.dart'; +import 'package:stackwallet/pages/receive_view/addresses/address_tag_editor_dialog.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; + +import '../../sample_data/theme_json.dart'; + +void main() { + Future openEditor( + WidgetTester tester, { + required List tags, + required Future Function(List) onSave, + bool desktop = false, + double textScale = 1, + }) async { + Util.screenWidth = desktop ? null : 320; + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = desktop + ? const Size(600, 420) + : const Size(320, 480); + addTearDown(() { + Util.screenWidth = null; + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => + AddressTagEditorDialog(tags: tags, onSave: onSave), + ), + child: const Text("Open editor"), + ), + ), + ), + ), + ); + await tester.tap(find.text("Open editor")); + await tester.pumpAndSettle(); + } + + testWidgets("normalizes additions and limits pasted input", (tester) async { + List? saved; + await openEditor( + tester, + tags: ["business"], + onSave: (tags) async => saved = tags, + ); + + final field = find.byType(TextField); + await tester.enterText(field, " BUSINESS "); + await tester.pump(); + expect( + tester + .widget(find.widgetWithText(PrimaryButton, "Add")) + .enabled, + isFalse, + ); + + await tester.enterText(field, List.filled(64, "x").join()); + await tester.pump(); + expect( + tester.widget(field).controller!.text, + hasLength(maxAddressTagLength), + ); + + await tester.enterText(field, " New Tag "); + await tester.tap(find.widgetWithText(PrimaryButton, "Add")); + await tester.pump(); + expect(find.text("new tag"), findsOneWidget); + + final saveButton = find.widgetWithText(PrimaryButton, "Save"); + await tester.ensureVisible(saveButton); + await tester.pumpAndSettle(); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + expect(saved, ["business", "new tag"]); + expect(find.byType(AddressTagEditorDialog), findsNothing); + }); + + testWidgets("refuses input that exceeds the tag length in code units", ( + tester, + ) async { + await openEditor(tester, tags: const [], onSave: (_) async {}); + + // 20 emoji: 20 characters, so the input formatter admits them, but 40 + // UTF-16 code units, which is what the length cap actually measures. + final emoji = List.filled(20, "\u{1F600}").join(); + final field = find.byType(TextField); + await tester.enterText(field, emoji); + await tester.pump(); + expect(tester.widget(field).controller!.text, emoji); + + final add = find.widgetWithText(PrimaryButton, "Add"); + expect(tester.widget(add).enabled, isFalse); + expect(find.text("Tag is too long"), findsOneWidget); + }); + + testWidgets("refuses input with no visible glyphs", (tester) async { + List? saved; + await openEditor( + tester, + tags: const [], + onSave: (tags) async => saved = tags, + ); + + await tester.enterText(find.byType(TextField), "\u200b"); + await tester.pump(); + final add = find.widgetWithText(PrimaryButton, "Add"); + expect(tester.widget(add).enabled, isFalse); + + await tester.tap(add, warnIfMissed: false); + await tester.pump(); + final save = find.widgetWithText(PrimaryButton, "Save"); + await tester.ensureVisible(save); + await tester.pumpAndSettle(); + await tester.tap(save); + await tester.pumpAndSettle(); + expect(saved, isEmpty); + }); + + testWidgets("keeps a failed save open and allows retry", (tester) async { + var attempts = 0; + await openEditor( + tester, + tags: const ["personal"], + onSave: (_) async { + attempts++; + if (attempts == 1) { + throw StateError("disk full"); + } + }, + ); + + final saveButton = find.widgetWithText(PrimaryButton, "Save"); + await tester.ensureVisible(saveButton); + await tester.pumpAndSettle(); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + expect(find.byKey(const Key("addressTagSaveError")), findsOneWidget); + expect(find.byType(AddressTagEditorDialog), findsOneWidget); + + await tester.ensureVisible(saveButton); + await tester.pumpAndSettle(); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + expect(attempts, 2); + expect(find.byType(AddressTagEditorDialog), findsNothing); + }); + + testWidgets("desktop editor scrolls bounded tags without overflow", ( + tester, + ) async { + final tags = List.generate( + maxAddressTagCount, + (index) => "tag-$index-${List.filled(40, "x").join()}", + ); + await openEditor( + tester, + tags: tags, + desktop: true, + textScale: 2, + onSave: (_) async {}, + ); + + expect( + find.text("Maximum of $maxAddressTagCount tags reached"), + findsOneWidget, + ); + expect(find.byTooltip("Remove ${tags.first} tag"), findsOneWidget); + expect( + tester + .widget(find.widgetWithText(PrimaryButton, "Add")) + .enabled, + isFalse, + ); + expect(tester.takeException(), isNull); + }); +} From 142b8be3c51e6666ad6f91738508c70c02882cc1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 17:21:10 -0500 Subject: [PATCH 2/2] feat: enable address tags display and editing Show tags on address cards, put the tag filter strip above the mobile and desktop address lists, and give the details view an editor entry point. Both lists now watch the shared providers instead of re-issuing the address query on every rebuild, and the desktop search is debounced. closes #408 --- .../receive_view/addresses/address_card.dart | 23 +- .../addresses/address_details_view.dart | 338 +++++++++--------- .../addresses/wallet_addresses_view.dart | 235 +++++------- .../sub_widgets/desktop_address_list.dart | 304 +++++++--------- 4 files changed, 389 insertions(+), 511 deletions(-) diff --git a/lib/pages/receive_view/addresses/address_card.dart b/lib/pages/receive_view/addresses/address_card.dart index f8b6ec8067..28080dfc32 100644 --- a/lib/pages/receive_view/addresses/address_card.dart +++ b/lib/pages/receive_view/addresses/address_card.dart @@ -41,6 +41,7 @@ import '../../../widgets/desktop/secondary_button.dart'; import '../../../widgets/qr.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_dialog.dart'; +import 'address_tag.dart'; class AddressCard extends ConsumerStatefulWidget { const AddressCard({ @@ -347,18 +348,16 @@ class _AddressCardState extends ConsumerState { ), ], ), - // if (label!.tags != null && label!.tags!.isNotEmpty) - // Wrap( - // spacing: 10, - // runSpacing: 10, - // children: label!.tags! - // .map( - // (e) => AddressTag( - // tag: e, - // ), - // ) - // .toList(), - // ), + if (label!.tags != null && label!.tags!.isNotEmpty) + const SizedBox(height: 10), + if (label!.tags != null && label!.tags!.isNotEmpty) + Wrap( + spacing: 10, + runSpacing: 10, + children: label!.tags! + .map((e) => AddressTag(tag: e)) + .toList(), + ), ], ), ); diff --git a/lib/pages/receive_view/addresses/address_details_view.dart b/lib/pages/receive_view/addresses/address_details_view.dart index aa6fd9be37..b0f8b89a77 100644 --- a/lib/pages/receive_view/addresses/address_details_view.dart +++ b/lib/pages/receive_view/addresses/address_details_view.dart @@ -42,6 +42,7 @@ import '../../wallet_view/transaction_views/transaction_details_view.dart' as tdv; import '../../wallet_view/transaction_views/tx_v2/transaction_v2_card.dart'; import 'address_tag.dart'; +import 'address_tag_editor_dialog.dart'; class AddressDetailsView extends ConsumerStatefulWidget { const AddressDetailsView({ @@ -71,60 +72,58 @@ class _AddressDetailsViewState extends ConsumerState { void _showDesktopAddressQrCode() { showDialog( context: context, - builder: - (context) => DesktopDialog( - maxWidth: 480, - maxHeight: 400, - child: Column( + builder: (context) => DesktopDialog( + maxWidth: 480, + maxHeight: 400, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Address QR code", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Address QR code", + style: STextStyles.desktopH3(context), + ), ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Center( - child: RepaintBoundary( - key: _qrKey, - child: QR( - data: AddressUtils.buildUriString( - ref.watch(pWalletCoin(widget.walletId)).uriScheme, - address.value, - {}, - ), - size: 220, - ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center( + child: RepaintBoundary( + key: _qrKey, + child: QR( + data: AddressUtils.buildUriString( + ref.watch(pWalletCoin(widget.walletId)).uriScheme, + address.value, + {}, ), + size: 220, ), - ], + ), ), - ), - const SizedBox(height: 32), - ], + ], + ), ), - ), + const SizedBox(height: 32), + ], + ), + ), ); } @override void initState() { - address = - MainDB.instance.isar.addresses - .where() - .idEqualTo(widget.addressId) - .findFirstSync()!; + address = MainDB.instance.isar.addresses + .where() + .idEqualTo(widget.addressId) + .findFirstSync()!; label = MainDB.instance.getAddressLabelSync(widget.walletId, address.value); Id? id = label?.id; @@ -133,12 +132,11 @@ class _AddressDetailsViewState extends ConsumerState { walletId: widget.walletId, addressString: address.value, value: "", - tags: - address.subType == AddressSubType.receiving - ? ["receiving"] - : address.subType == AddressSubType.change - ? ["change"] - : null, + tags: address.subType == AddressSubType.receiving + ? ["receiving"] + : address.subType == AddressSubType.change + ? ["change"] + : null, ); id = MainDB.instance.putAddressLabelSync(label!); } @@ -153,46 +151,45 @@ class _AddressDetailsViewState extends ConsumerState { final wallet = ref.watch(pWallets).getWallet(widget.walletId); return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of( - context, - ).extension()!.backgroundAppBar, - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - titleSpacing: 0, - title: Text( - "Address details", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: child, - ), - ), - ); - }, - ), - ), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + titleSpacing: 0, + title: Text( + "Address details", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + }, ), ), + ), + ), child: StreamBuilder( stream: stream, builder: (context, snapshot) { @@ -215,14 +212,14 @@ class _AddressDetailsViewState extends ConsumerState { children: [ Text( "Address details", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textSubtitle1, - ), + ), ), CustomTextButton( text: "View QR code", @@ -233,10 +230,9 @@ class _AddressDetailsViewState extends ConsumerState { const SizedBox(height: 4), RoundedWhiteContainer( padding: EdgeInsets.zero, - borderColor: - Theme.of( - context, - ).extension()!.backgroundAppBar, + borderColor: Theme.of( + context, + ).extension()!.backgroundAppBar, child: child, ), const SizedBox(height: 16), @@ -245,38 +241,37 @@ class _AddressDetailsViewState extends ConsumerState { children: [ Text( "Transaction history", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textSubtitle1, - ), + ), ), ], ), const SizedBox(height: 8), RoundedWhiteContainer( padding: EdgeInsets.zero, - borderColor: - Theme.of( - context, - ).extension()!.backgroundAppBar, + borderColor: Theme.of( + context, + ).extension()!.backgroundAppBar, child: ref - .watch(pWallets) - .getWallet(widget.walletId) - .isarTransactionVersion == - 2 - ? _AddressDetailsTxV2List( - walletId: widget.walletId, - address: address, - ) - : _AddressDetailsTxList( - walletId: widget.walletId, - address: address, - ), + .watch(pWallets) + .getWallet(widget.walletId) + .isarTransactionVersion == + 2 + ? _AddressDetailsTxV2List( + walletId: widget.walletId, + address: address, + ) + : _AddressDetailsTxList( + walletId: widget.walletId, + address: address, + ), ), ], ), @@ -305,10 +300,9 @@ class _AddressDetailsViewState extends ConsumerState { DetailItem( title: "Address", detail: address.value, - button: - isDesktop - ? tdv.IconCopyButton(data: address.value) - : SimpleCopyButton(data: address.value), + button: isDesktop + ? tdv.IconCopyButton(data: address.value) + : SimpleCopyButton(data: address.value), ), const _Div(height: 12), DetailItem( @@ -325,7 +319,12 @@ class _AddressDetailsViewState extends ConsumerState { ), ), const _Div(height: 12), - _Tags(tags: label!.tags), + _Tags( + tags: label!.tags, + onTagsChanged: (tags) => ref + .read(mainDBProvider) + .putAddressLabel(label!.copyWith(tags: tags)), + ), if (address.derivationPath != null) const _Div(height: 12), if (address.derivationPath != null) DetailItem( @@ -379,8 +378,9 @@ class _AddressDetailsViewState extends ConsumerState { "Transactions", textAlign: TextAlign.left, style: STextStyles.itemSubtitle(context).copyWith( - color: - Theme.of(context).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, ), ), if (!isDesktop) const SizedBox(height: 12), @@ -391,13 +391,13 @@ class _AddressDetailsViewState extends ConsumerState { .isarTransactionVersion == 2 ? _AddressDetailsTxV2List( - walletId: widget.walletId, - address: address, - ) + walletId: widget.walletId, + address: address, + ) : _AddressDetailsTxList( - walletId: widget.walletId, - address: address, - ), + walletId: widget.walletId, + address: address, + ), ], ), ); @@ -432,9 +432,8 @@ class _AddressDetailsTxList extends StatelessWidget { return ListView.separated( shrinkWrap: true, primary: false, - itemBuilder: - (_, index) => - TransactionCard(transaction: txns[index], walletId: walletId), + itemBuilder: (_, index) => + TransactionCard(transaction: txns[index], walletId: walletId), separatorBuilder: (_, __) => const _Div(height: 1), itemCount: count, ); @@ -443,14 +442,10 @@ class _AddressDetailsTxList extends StatelessWidget { padding: EdgeInsets.zero, child: Column( mainAxisSize: MainAxisSize.min, - children: - query - .findAllSync() - .map( - (e) => - TransactionCard(transaction: e, walletId: walletId), - ) - .toList(), + children: query + .findAllSync() + .map((e) => TransactionCard(transaction: e, walletId: walletId)) + .toList(), ), ); } @@ -472,8 +467,10 @@ class _AddressDetailsTxV2List extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final walletTxFilter = - ref.watch(pWallets).getWallet(walletId).transactionFilterOperation; + final walletTxFilter = ref + .watch(pWallets) + .getWallet(walletId) + .transactionFilterOperation; final query = ref .watch(mainDBProvider) @@ -513,8 +510,8 @@ class _AddressDetailsTxV2List extends ConsumerWidget { return ListView.separated( shrinkWrap: true, primary: false, - itemBuilder: - (_, index) => TransactionCardV2(transaction: txns[index]), + itemBuilder: (_, index) => + TransactionCardV2(transaction: txns[index]), separatorBuilder: (_, __) => const _Div(height: 1), itemCount: count, ); @@ -523,11 +520,10 @@ class _AddressDetailsTxV2List extends ConsumerWidget { padding: EdgeInsets.zero, child: Column( mainAxisSize: MainAxisSize.min, - children: - query - .findAllSync() - .map((e) => TransactionCardV2(transaction: e)) - .toList(), + children: query + .findAllSync() + .map((e) => TransactionCardV2(transaction: e)) + .toList(), ), ); } @@ -557,9 +553,10 @@ class _Div extends StatelessWidget { } class _Tags extends StatelessWidget { - const _Tags({super.key, required this.tags}); + const _Tags({super.key, required this.tags, required this.onTagsChanged}); final List? tags; + final Future Function(List) onTagsChanged; @override Widget build(BuildContext context) { @@ -571,28 +568,33 @@ class _Tags extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text("Tags", style: STextStyles.itemSubtitle(context)), - Container(), - // SimpleEditButton( - // onPressedOverride: () { - // // TODO edit tags - // }, - // ), + SimpleEditButton( + onPressedOverride: () => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AddressTagEditorDialog( + tags: tags ?? const [], + onSave: onTagsChanged, + ), + ), + ), ], ), const SizedBox(height: 8), tags != null && tags!.isNotEmpty ? Wrap( - spacing: 10, - runSpacing: 10, - children: tags!.map((e) => AddressTag(tag: e)).toList(), - ) + spacing: 10, + runSpacing: 10, + children: tags!.map((e) => AddressTag(tag: e)).toList(), + ) : Text( - "Tags will appear here", - style: STextStyles.w500_14(context).copyWith( - color: - Theme.of(context).extension()!.textSubtitle3, + "Tags will appear here", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle3, + ), ), - ), ], ), ); diff --git a/lib/pages/receive_view/addresses/wallet_addresses_view.dart b/lib/pages/receive_view/addresses/wallet_addresses_view.dart index 7dcf0ea840..2a6e0c9e50 100644 --- a/lib/pages/receive_view/addresses/wallet_addresses_view.dart +++ b/lib/pages/receive_view/addresses/wallet_addresses_view.dart @@ -10,11 +10,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 '../../../themes/stack_colors.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; @@ -25,6 +22,8 @@ import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/loading_indicator.dart'; import 'address_card.dart'; import 'address_details_view.dart'; +import 'address_tag_data.dart'; +import 'address_tag_filter.dart'; class WalletAddressesView extends ConsumerStatefulWidget { const WalletAddressesView({super.key, required this.walletId}); @@ -42,107 +41,11 @@ class _WalletAddressesViewState extends ConsumerState { final bool isDesktop = Util.isDesktop; final String _searchString = ""; + String? _selectedTag; // late final TextEditingController _searchController; // final searchFieldFocusNode = FocusNode(); - Future> _search(String term) async { - if (term.isEmpty) { - return MainDB.instance - .getAddresses(widget.walletId) - .filter() - .group( - (q) => q - .subTypeEqualTo(AddressSubType.change) - .or() - .subTypeEqualTo(AddressSubType.receiving) - .or() - .subTypeEqualTo(AddressSubType.paynymReceive) - .or() - .subTypeEqualTo(AddressSubType.paynymNotification), - ) - .and() - .not() - .typeEqualTo(AddressType.nonWallet) - .and() - .group( - (q) => q - .group( - (q2) => q2 - .typeEqualTo(AddressType.frostMS) - .and() - .zSafeFrostEqualTo(true), - ) - .or() - .not() - .typeEqualTo(AddressType.frostMS), - ) - .sortByDerivationIndex() - .idProperty() - .findAll(); - } - - final labels = - await MainDB.instance - .getAddressLabels(widget.walletId) - .filter() - .group( - (q) => q - .valueContains(term, caseSensitive: false) - .or() - .addressStringContains(term, caseSensitive: false) - .or() - .group( - (q) => q.tagsIsNotNull().and().tagsElementContains( - term, - caseSensitive: false, - ), - ), - ) - .findAll(); - - if (labels.isEmpty) { - return []; - } - - return MainDB.instance - .getAddresses(widget.walletId) - .filter() - .anyOf( - labels, - (q, e) => q.valueEqualTo(e.addressString), - ) - .group( - (q) => q - .subTypeEqualTo(AddressSubType.change) - .or() - .subTypeEqualTo(AddressSubType.receiving) - .or() - .subTypeEqualTo(AddressSubType.paynymReceive) - .or() - .subTypeEqualTo(AddressSubType.paynymNotification), - ) - .and() - .not() - .typeEqualTo(AddressType.nonWallet) - .and() - .group( - (q) => q - .group( - (q2) => q2 - .typeEqualTo(AddressType.frostMS) - .and() - .zSafeFrostEqualTo(true), - ) - .or() - .not() - .typeEqualTo(AddressType.frostMS), - ) - .sortByDerivationIndex() - .idProperty() - .findAll(); - } - // @override // void initState() { // _searchController = TextEditingController(); @@ -160,33 +63,56 @@ class _WalletAddressesViewState extends ConsumerState { @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(widget.walletId)); + final tags = ref + .watch(walletAddressTagsProvider(widget.walletId)) + .when( + data: (value) => value, + error: (_, __) => const [], + loading: () => const [], + ); + final selectedTag = reconcileSelectedAddressTag(_selectedTag, tags); + if (selectedTag != _selectedTag) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _selectedTag != selectedTag) { + setState(() => _selectedTag = selectedTag); + } + }); + } + final ids = ref.watch( + filteredWalletAddressIdsProvider( + WalletAddressFilter( + walletId: widget.walletId, + searchTerm: _searchString, + tag: selectedTag, + ), + ), + ); return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of( - context, - ).extension()!.backgroundAppBar, - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - titleSpacing: 0, - title: Text( - "Wallet addresses", - style: STextStyles.navBarTitle(context), - ), - ), - body: Padding(padding: const EdgeInsets.all(16), child: child), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + titleSpacing: 0, + title: Text( + "Wallet addresses", + style: STextStyles.navBarTitle(context), ), ), + body: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), child: SafeArea( child: Column( children: [ @@ -258,38 +184,41 @@ class _WalletAddressesViewState extends ConsumerState { // SizedBox( // height: isDesktop ? 20 : 16, // ), + if (tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: AddressTagFilter( + tags: tags, + selectedTag: selectedTag, + onSelected: (tag) => setState(() => _selectedTag = tag), + ), + ), Expanded( - child: FutureBuilder( - future: _search(_searchString), - builder: (context, AsyncSnapshot> snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.data != null) { - // listview - return ListView.separated( - itemCount: snapshot.data!.length, - separatorBuilder: (_, __) => Container(height: 10), - itemBuilder: - (_, index) => AddressCard( - walletId: widget.walletId, - addressId: snapshot.data![index], - coin: coin, - onPressed: () { - Navigator.of(context).pushNamed( - AddressDetailsView.routeName, - arguments: Tuple2( - snapshot.data![index], - widget.walletId, - ), - ); - }, - ), - ); - } else { - return const Center( - child: LoadingIndicator(height: 200, width: 200), - ); - } - }, + child: ids.when( + data: (addressIds) => ListView.separated( + itemCount: addressIds.length, + separatorBuilder: (_, __) => Container(height: 10), + itemBuilder: (_, index) => AddressCard( + walletId: widget.walletId, + addressId: addressIds[index], + coin: coin, + onPressed: () { + Navigator.of(context).pushNamed( + AddressDetailsView.routeName, + arguments: Tuple2(addressIds[index], widget.walletId), + ); + }, + ), + ), + error: (_, __) => Center( + child: Text( + "Couldn't load addresses", + style: STextStyles.w500_14(context), + ), + ), + loading: () => const Center( + child: LoadingIndicator(height: 200, width: 200), + ), ), ), ], diff --git a/lib/pages_desktop_specific/addresses/sub_widgets/desktop_address_list.dart b/lib/pages_desktop_specific/addresses/sub_widgets/desktop_address_list.dart index 67a3155821..c4325d6b25 100644 --- a/lib/pages_desktop_specific/addresses/sub_widgets/desktop_address_list.dart +++ b/lib/pages_desktop_specific/addresses/sub_widgets/desktop_address_list.dart @@ -8,14 +8,15 @@ * */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:isar_community/isar.dart'; -import '../../../models/isar/models/isar_models.dart'; import '../../../pages/receive_view/addresses/address_card.dart'; -import '../../../providers/db/main_db_provider.dart'; +import '../../../pages/receive_view/addresses/address_tag_data.dart'; +import '../../../pages/receive_view/addresses/address_tag_filter.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; @@ -23,6 +24,7 @@ import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../widgets/icon_widgets/x_icon.dart'; +import '../../../widgets/loading_indicator.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; @@ -46,110 +48,12 @@ class _DesktopAddressListState extends ConsumerState { final bool isDesktop = Util.isDesktop; String _searchString = ""; + String? _selectedTag; + Timer? _searchDebounce; late final TextEditingController _searchController; final searchFieldFocusNode = FocusNode(); - List _search(String term) { - if (term.isEmpty) { - return ref - .read(mainDBProvider) - .getAddresses(widget.walletId) - .filter() - .group( - (q) => q - .subTypeEqualTo(AddressSubType.change) - .or() - .subTypeEqualTo(AddressSubType.receiving) - .or() - .subTypeEqualTo(AddressSubType.paynymReceive) - .or() - .subTypeEqualTo(AddressSubType.paynymNotification), - ) - .and() - .not() - .typeEqualTo(AddressType.nonWallet) - .and() - .group( - (q) => q - .group( - (q2) => q2 - .typeEqualTo(AddressType.frostMS) - .and() - .zSafeFrostEqualTo(true), - ) - .or() - .not() - .typeEqualTo(AddressType.frostMS), - ) - .sortByDerivationIndex() - .idProperty() - .findAllSync(); - } - - final labels = - ref - .read(mainDBProvider) - .getAddressLabels(widget.walletId) - .filter() - .group( - (q) => q - .valueContains(term, caseSensitive: false) - .or() - .addressStringContains(term, caseSensitive: false) - .or() - .group( - (q) => q.tagsIsNotNull().and().tagsElementContains( - term, - caseSensitive: false, - ), - ), - ) - .findAllSync(); - - if (labels.isEmpty) { - return []; - } - - return ref - .read(mainDBProvider) - .getAddresses(widget.walletId) - .filter() - .anyOf( - labels, - (q, e) => q.valueEqualTo(e.addressString), - ) - .group( - (q) => q - .subTypeEqualTo(AddressSubType.change) - .or() - .subTypeEqualTo(AddressSubType.receiving) - .or() - .subTypeEqualTo(AddressSubType.paynymReceive) - .or() - .subTypeEqualTo(AddressSubType.paynymNotification), - ) - .and() - .not() - .typeEqualTo(AddressType.nonWallet) - .and() - .group( - (q) => q - .group( - (q2) => q2 - .typeEqualTo(AddressType.frostMS) - .and() - .zSafeFrostEqualTo(true), - ) - .or() - .not() - .typeEqualTo(AddressType.frostMS), - ) - .sortByDerivationIndex() - .idProperty() - .findAllSync(); - } - @override void initState() { _searchController = TextEditingController(); @@ -159,6 +63,7 @@ class _DesktopAddressListState extends ConsumerState { @override void dispose() { + _searchDebounce?.cancel(); _searchController.dispose(); searchFieldFocusNode.dispose(); super.dispose(); @@ -167,8 +72,30 @@ class _DesktopAddressListState extends ConsumerState { @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(widget.walletId)); - - final ids = _search(_searchString); + final tags = ref + .watch(walletAddressTagsProvider(widget.walletId)) + .when( + data: (value) => value, + error: (_, __) => const [], + loading: () => const [], + ); + final selectedTag = reconcileSelectedAddressTag(_selectedTag, tags); + if (selectedTag != _selectedTag) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _selectedTag != selectedTag) { + setState(() => _selectedTag = selectedTag); + } + }); + } + final ids = ref.watch( + filteredWalletAddressIdsProvider( + WalletAddressFilter( + walletId: widget.walletId, + searchTerm: _searchString, + tag: selectedTag, + ), + ), + ); return Column( children: [ @@ -185,92 +112,113 @@ class _DesktopAddressListState extends ConsumerState { controller: _searchController, focusNode: searchFieldFocusNode, onChanged: (value) { - setState(() { - _searchString = value; - }); + _searchDebounce?.cancel(); + setState(() {}); + _searchDebounce = Timer( + const Duration(milliseconds: 250), + () { + if (mounted) { + setState(() => _searchString = value); + } + }, + ); }, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: EdgeInsets.symmetric( - horizontal: isDesktop ? 12 : 10, - vertical: isDesktop ? 18 : 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: isDesktop ? 20 : 16, - height: isDesktop ? 20 : 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 12 : 10, + vertical: isDesktop ? 18 : 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: isDesktop ? 20 : 16, + height: isDesktop ? 20 : 16, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + _searchDebounce?.cancel(); + setState(() { + _searchController.text = ""; + _searchString = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), ), + if (tags.isNotEmpty) const SizedBox(height: 16), + if (tags.isNotEmpty) + AddressTagFilter( + tags: tags, + selectedTag: selectedTag, + onSelected: (tag) => setState(() => _selectedTag = tag), + ), const SizedBox(height: 20), Expanded( child: RoundedWhiteContainer( padding: EdgeInsets.zero, - child: ListView.separated( - shrinkWrap: true, - itemCount: ids.length, - separatorBuilder: - (_, __) => Container( - height: 1, - color: - Theme.of( - context, - ).extension()!.backgroundAppBar, - ), - itemBuilder: - (_, index) => Padding( - padding: const EdgeInsets.all(4), - child: AddressCard( - key: Key("addressCardDesktop_key_${ids[index]}"), - walletId: widget.walletId, - addressId: ids[index], - coin: coin, - onPressed: () { - ref.read(desktopSelectedAddressId.state).state = - ids[index]; - }, - ), + child: ids.when( + data: (addressIds) => ListView.separated( + shrinkWrap: true, + itemCount: addressIds.length, + separatorBuilder: (_, __) => Container( + height: 1, + color: Theme.of( + context, + ).extension()!.backgroundAppBar, + ), + itemBuilder: (_, index) => Padding( + padding: const EdgeInsets.all(4), + child: AddressCard( + key: Key("addressCardDesktop_key_${addressIds[index]}"), + walletId: widget.walletId, + addressId: addressIds[index], + coin: coin, + onPressed: () { + ref.read(desktopSelectedAddressId.state).state = + addressIds[index]; + }, ), + ), + ), + error: (_, __) => Center( + child: Text( + "Couldn't load addresses", + style: STextStyles.w500_14(context), + ), + ), + loading: () => const Center( + child: LoadingIndicator(height: 200, width: 200), + ), ), ), ),