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/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/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), + ), ), ), ), 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); + }); +}