From 69b312d009de4a25fefc1f648af6c4195ea2533d Mon Sep 17 00:00:00 2001 From: Isaac Banner Date: Mon, 20 Jul 2026 17:42:13 -0700 Subject: [PATCH 1/2] feat(collection): tap a tag chip to filter the Collection to that tag (#414) Tapping a tag chip where a user reads a dance's tags (dance detail/summary and Collection list rows) now switches to the Collection list and applies a clean single-tag filter, so callers can quickly find all dances sharing it. - Add CollectionFilterController/CollectionFilterScope (provided above the root Navigator in main.dart, mirroring CollectionRefreshScope) so pushed detail routes can request a filter. - AppShell subscribes and switches to the Collection tab + pops to the list. - DanceListScreen applies REPLACE semantics: clear prior FTS/facets/advanced, then set the tapped tag id on the existing tag facet and re-search. The active filter remains visible (Filters badge + tag facet chip) and clearable. - Make detail-screen and list-row tag chips tappable ActionChips (detail guarded to non-preview; list rows opt in via onTagTap so the Programs dance picker is unaffected). Carry tag ids alongside names in the data classes so filtering matches by id, not display text. Reuses the existing id-based TagFilter facet; no core changes. Tag ids come from the local DB and flow into the parameterized TagFilter; tag names are display-only, so special characters are safe. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- app/lib/main.dart | 14 +- app/lib/src/data/collection_filter_scope.dart | 75 +++++ app/lib/src/models/dance_list_entry.dart | 7 + app/lib/src/screens/app_shell.dart | 45 +++ app/lib/src/screens/dance_detail_screen.dart | 41 ++- app/lib/src/screens/dance_list_screen.dart | 61 ++++ app/lib/src/search/collection_data.dart | 4 + app/lib/src/search/dance_detail_data.dart | 12 + app/lib/src/widgets/dance_list_tile.dart | 32 +- app/test/tag_filter_navigation_test.dart | 285 ++++++++++++++++++ 10 files changed, 558 insertions(+), 18 deletions(-) create mode 100644 app/lib/src/data/collection_filter_scope.dart create mode 100644 app/test/tag_filter_navigation_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index 5e6684e7..5eb7e75e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -8,6 +8,7 @@ import 'src/data/app_database.dart'; import 'src/data/app_theme_scope.dart'; import 'src/data/archive_intake_service.dart'; import 'src/data/backup_controller_scope.dart'; +import 'src/data/collection_filter_scope.dart'; import 'src/data/collection_refresh_scope.dart'; import 'src/data/confirm_before_delete_scope.dart'; import 'src/data/custom_themes_controller.dart'; @@ -192,6 +193,12 @@ class _CompendiumAppState extends State { /// Collection list re-boots without a relaunch. Exposed via /// [CollectionRefreshScope]. final ValueNotifier _collectionRefreshNotifier = ValueNotifier(0); + + /// App-level "tap a tag → show the Collection filtered to it" coordinator + /// (issue #414). Provided via [CollectionFilterScope] above the root + /// navigator so pushed detail routes can reach it. + final CollectionFilterController _collectionFilterController = + CollectionFilterController(); late final CustomThemesController _customThemes; late final FormationColorsController _formationColors; late final DialectLibraryController _dialectLibrary; @@ -549,6 +556,7 @@ class _CompendiumAppState extends State { _setListColorCodingNotifier.dispose(); _dateFormatNotifier.dispose(); _collectionRefreshNotifier.dispose(); + _collectionFilterController.dispose(); _customThemes.dispose(); _formationColors.dispose(); _dialectLibrary.removeListener(_syncActiveDialect); @@ -707,7 +715,11 @@ class _CompendiumAppState extends State { child: CollectionRefreshScope( revision: _collectionRefreshNotifier, - child: child!, + child: CollectionFilterScope( + controller: + _collectionFilterController, + child: child!, + ), ), ), ), diff --git a/app/lib/src/data/collection_filter_scope.dart b/app/lib/src/data/collection_filter_scope.dart new file mode 100644 index 00000000..36b71663 --- /dev/null +++ b/app/lib/src/data/collection_filter_scope.dart @@ -0,0 +1,75 @@ +import 'package:flutter/widgets.dart'; + +/// A pending "filter the Collection to this tag" request (issue #414). +/// +/// [seq] is a monotonically increasing token so that tapping the *same* tag +/// twice still counts as a new request — listeners compare the seq they last +/// applied rather than the tag id, so a repeat tap re-triggers navigation and +/// re-applies the filter (e.g. after the user cleared it). +@immutable +class CollectionTagFilterRequest { + const CollectionTagFilterRequest({required this.tagId, required this.seq}); + + /// The id of the tag to filter the Collection list to. + final String tagId; + + /// Unique, increasing token distinguishing this request from earlier ones. + final int seq; + + @override + bool operator ==(Object other) => + other is CollectionTagFilterRequest && + other.tagId == tagId && + other.seq == seq; + + @override + int get hashCode => Object.hash(tagId, seq); +} + +/// Coordinates a "tap a tag → show the Collection filtered to that tag" action +/// (issue #414) across the app without coupling the tag chips (dance detail, +/// list rows) to the Collection screen or the top-level shell. +/// +/// A tag chip calls [filterByTag]; the running [AppShell] switches to the +/// Collection destination (and pops any pushed detail route), and the live +/// `DanceListScreen` applies a single-tag filter — both by listening to this +/// controller. Mirrors the notifier pattern used by `CollectionRefreshScope`. +class CollectionFilterController extends ChangeNotifier { + CollectionTagFilterRequest? _pending; + int _seq = 0; + + /// The most recent filter request, or `null` if none has been made yet. + CollectionTagFilterRequest? get pending => _pending; + + /// Requests that the Collection list be shown filtered to [tagId]. Notifies + /// listeners so the shell navigates and the list applies the filter. + void filterByTag(String tagId) { + _pending = CollectionTagFilterRequest(tagId: tagId, seq: ++_seq); + notifyListeners(); + } +} + +/// Exposes a [CollectionFilterController] to the widget tree. +/// +/// Provided in `main.dart` **above the root [Navigator]** (in +/// `MaterialApp.builder`), so it is reachable both from the Collection screen +/// kept alive in the shell's `IndexedStack` and from a `DanceDetailScreen` +/// pushed as a route on the root navigator — mirroring how +/// `CollectionRefreshScope` is wired. +/// +/// Optional by design: [maybeOf] returns `null` in focused widget tests that +/// don't mount it (and in the online-preview detail view, where filtering the +/// local collection is meaningless), so callers must null-check. +class CollectionFilterScope + extends InheritedNotifier { + const CollectionFilterScope({ + super.key, + required CollectionFilterController controller, + required super.child, + }) : super(notifier: controller); + + /// The controller, or `null` when no scope is present. + static CollectionFilterController? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.notifier; +} diff --git a/app/lib/src/models/dance_list_entry.dart b/app/lib/src/models/dance_list_entry.dart index 98e60bb7..33fdf456 100644 --- a/app/lib/src/models/dance_list_entry.dart +++ b/app/lib/src/models/dance_list_entry.dart @@ -8,6 +8,7 @@ class DanceListEntry { required this.dance, required this.authorNames, required this.tagNames, + this.tags = const [], required this.listCustomFields, required this.callCounts, this.lastCalled, @@ -17,6 +18,12 @@ class DanceListEntry { final List authorNames; final List tagNames; + /// The dance's tags as `(id, name)` pairs, in [Dance.tagIds] order, for tags + /// whose name resolves. Carries the id (unlike [tagNames]) so a tapped tag + /// chip in a list row can drive the Collection's id-based tag filter + /// (issue #414). + final List<({String id, String name})> tags; + /// `showInList` custom field values as `label: display value` pairs, in /// [CustomFieldDef] declaration order. final List listCustomFields; diff --git a/app/lib/src/screens/app_shell.dart b/app/lib/src/screens/app_shell.dart index 3a28c45d..3df46e57 100644 --- a/app/lib/src/screens/app_shell.dart +++ b/app/lib/src/screens/app_shell.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../data/collection_filter_scope.dart'; import '../screens/dance_detail_screen.dart'; import '../screens/program_editor_screen.dart'; import '../theme/app_spacing.dart'; @@ -37,6 +38,50 @@ class AppShell extends StatefulWidget { class _AppShellState extends State { int _index = 0; + /// The app-level tag-filter coordinator (issue #414). Subscribed so a tag tap + /// anywhere switches to the Collection destination and reveals the (now + /// filtered) list. Tracked so the listener is swapped/removed correctly. + CollectionFilterController? _filterController; + + /// The seq of the last tag-filter request this shell reacted to, so a repeat + /// request (even for the same tag) is handled exactly once. + int _lastFilterSeq = 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final controller = CollectionFilterScope.maybeOf(context); + if (!identical(controller, _filterController)) { + _filterController?.removeListener(_onTagFilterRequested); + _filterController = controller; + _filterController?.addListener(_onTagFilterRequested); + } + } + + @override + void dispose() { + _filterController?.removeListener(_onTagFilterRequested); + super.dispose(); + } + + /// Reacts to a "filter the Collection to this tag" request: selects the + /// Collection destination and pops any pushed route (e.g. a full-screen dance + /// detail on narrow layouts) so the filtered list is visible. The live + /// [DanceListScreen] applies the actual filter by listening to the same + /// controller. A no-op pop on wide layouts, where detail is embedded. + void _onTagFilterRequested() { + final request = _filterController?.pending; + if (request == null || request.seq == _lastFilterSeq) return; + _lastFilterSeq = request.seq; + if (!mounted) return; + setState(() => _index = 0); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + }); + } + /// The core destinations shared by both layouts: rail destinations on wide /// and the first three bottom-bar destinations on narrow. The User Guide is /// index [_guideIndex] — a fourth [IndexedStack] page reached from the diff --git a/app/lib/src/screens/dance_detail_screen.dart b/app/lib/src/screens/dance_detail_screen.dart index 3d068931..09512fd4 100644 --- a/app/lib/src/screens/dance_detail_screen.dart +++ b/app/lib/src/screens/dance_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:printing/printing.dart'; import 'package:share_plus/share_plus.dart'; import '../data/active_dialect_scope.dart'; +import '../data/collection_filter_scope.dart'; import '../data/dialect_library_scope.dart'; import '../data/display_defaults.dart'; import '../data/formation_colors_scope.dart'; @@ -700,17 +701,37 @@ class _DanceDetailScreenState extends State { ), ), ), - if (detail.tagNames.isNotEmpty) ...[ + if (detail.tags.isNotEmpty) ...[ const SizedBox(height: AppSpacing.md), - Wrap( - spacing: 8, - children: [ - for (final tag in detail.tagNames) - Chip( - avatar: const Icon(Icons.label_outline, size: 16), - label: Text(tag), - ), - ], + Builder( + builder: (context) { + // Tapping a tag filters the Collection to it (issue #414). Only + // wired for a saved dance (not an online preview) and only when + // the app-level coordinator scope is present; otherwise the chips + // stay non-interactive, preserving prior behaviour. + final filter = _isPreview + ? null + : CollectionFilterScope.maybeOf(context); + return Wrap( + spacing: 8, + children: [ + for (final tag in detail.tags) + if (filter != null) + ActionChip( + key: ValueKey('tag-filter-chip-${tag.id}'), + avatar: const Icon(Icons.label_outline, size: 16), + label: Text(tag.name), + tooltip: 'Show dances tagged “${tag.name}”', + onPressed: () => filter.filterByTag(tag.id), + ) + else + Chip( + avatar: const Icon(Icons.label_outline, size: 16), + label: Text(tag.name), + ), + ], + ); + }, ), ], const SizedBox(height: AppSpacing.lg), diff --git a/app/lib/src/screens/dance_list_screen.dart b/app/lib/src/screens/dance_list_screen.dart index c3f5c12a..02f7cddb 100644 --- a/app/lib/src/screens/dance_list_screen.dart +++ b/app/lib/src/screens/dance_list_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_slidable/flutter_slidable.dart'; import '../data/active_dialect_scope.dart'; import '../data/callersbox_online.dart'; +import '../data/collection_filter_scope.dart'; import '../data/collection_refresh_scope.dart'; import '../data/contradb_online.dart'; import '../data/dialect_library_scope.dart'; @@ -172,6 +173,15 @@ class _DanceListScreenState extends State { /// from Settings) bumps it. Tracked so listeners are swapped correctly. ValueListenable? _collectionRefresh; + /// The app-level tag-filter coordinator (issue #414). When a tag chip is + /// tapped (here, on a dance detail, or on a list row), this list applies a + /// single-tag filter. Tracked so the listener is swapped correctly. + CollectionFilterController? _filterController; + + /// The seq of the last tag-filter request applied, so a repeat request (even + /// for the same tag) re-applies exactly once. + int _lastFilterSeq = 0; + CollectionData? _data; Object? _loadError; @@ -258,6 +268,20 @@ class _DanceListScreenState extends State { _collectionRefresh = refresh; _collectionRefresh?.addListener(_onRefreshTriggered); } + + // Subscribe to the app-level tag-filter coordinator (issue #414). A tag tap + // anywhere publishes a request; this list reacts by applying a single-tag + // filter. Registers a rebuild dependency; the controller is stable across + // the app's lifetime, so this attaches once. + final filter = CollectionFilterScope.maybeOf(context); + if (!identical(filter, _filterController)) { + _filterController?.removeListener(_onTagFilterRequested); + _filterController = filter; + _filterController?.addListener(_onTagFilterRequested); + // Apply any request that arrived before we subscribed (e.g. the very + // first frame), so a tag tap on the initial screen isn't missed. + _onTagFilterRequested(); + } } @override @@ -282,10 +306,46 @@ class _DanceListScreenState extends State { if (mounted) _boot(); } + /// Reacts to an app-level "filter the Collection to this tag" request (issue + /// #414). Applies a **single-tag** filter that *replaces* the current query: + /// arriving from a dance detail with unknown prior filter state, a clean + /// "show every dance with this tag" result is the predictable behavior (the + /// user can then clear it via the Filters panel). Handled once per request. + void _onTagFilterRequested() { + final request = _filterController?.pending; + if (request == null || request.seq == _lastFilterSeq) return; + _lastFilterSeq = request.seq; + if (!mounted) return; + _applyExternalTagFilter(request.tagId); + } + + /// Replaces the current search with a single-tag facet filter for [tagId] and + /// re-runs the search. Clears the text query, other facets, by-phrase and + /// advanced state, and exits online mode so the (local) filtered list is what + /// the user lands on. + void _applyExternalTagFilter(String tagId) { + _debounceTimer?.cancel(); + setState(() { + _ftsController.clear(); + _facets.clear(); + _byPhrase.clear(); + _advancedRoot.children.clear(); + _advancedRoot.kind = GroupKind.all; + _advancedEnabled = false; + _onlineEnabled = false; + _onlineResults = const []; + _onlineError = null; + _onlineSearching = false; + _facets.tagIds.add(tagId); + }); + _runSearch(); + } + @override void dispose() { widget.refreshTrigger?.removeListener(_onRefreshTriggered); _collectionRefresh?.removeListener(_onRefreshTriggered); + _filterController?.removeListener(_onTagFilterRequested); _debounceTimer?.cancel(); _ftsController.dispose(); super.dispose(); @@ -1572,6 +1632,7 @@ class _DanceListScreenState extends State { } }, onDuplicate: () => _duplicateFromList(entry.dance.id), + onTagTap: _applyExternalTagFilter, onAddToProgram: () => showAddToProgramSheet( context, repositories: _repos, diff --git a/app/lib/src/search/collection_data.dart b/app/lib/src/search/collection_data.dart index b2f887ea..00aafc62 100644 --- a/app/lib/src/search/collection_data.dart +++ b/app/lib/src/search/collection_data.dart @@ -183,6 +183,10 @@ class CollectionData { for (final id in dance.tagIds) if (tagNames[id] != null) tagNames[id]!, ], + tags: [ + for (final id in dance.tagIds) + if (tagNames[id] != null) (id: id, name: tagNames[id]!), + ], listCustomFields: [ for (final def in listFieldDefs) for (final value in dance.customFields) diff --git a/app/lib/src/search/dance_detail_data.dart b/app/lib/src/search/dance_detail_data.dart index ec2cbafe..c1f9f5f6 100644 --- a/app/lib/src/search/dance_detail_data.dart +++ b/app/lib/src/search/dance_detail_data.dart @@ -19,6 +19,7 @@ class DanceDetailData { required this.dance, required this.authorNames, required this.tagNames, + this.tags = const [], required this.customFields, required this.relatedDanceTitles, required this.sourcesById, @@ -30,6 +31,13 @@ class DanceDetailData { final Dance dance; final List authorNames; final List tagNames; + + /// The dance's tags as `(id, name)` pairs, in [Dance.tagIds] order, for tags + /// whose name resolves. Carries the id (unlike [tagNames]) so a tapped tag + /// chip can drive the Collection's id-based tag filter (issue #414). Empty in + /// the online-preview constructors (an un-imported dance has no tags to + /// filter the local collection by). + final List<({String id, String name})> tags; final List customFields; /// Maps targetDanceId → title for relatedDance links whose target exists. @@ -128,6 +136,10 @@ class DanceDetailData { for (final id in dance.tagIds) if (tagNames[id] != null) tagNames[id]!, ], + tags: [ + for (final id in dance.tagIds) + if (tagNames[id] != null) (id: id, name: tagNames[id]!), + ], customFields: [ for (final value in dance.customFields) if (defsById[value.fieldId] case final def?) diff --git a/app/lib/src/widgets/dance_list_tile.dart b/app/lib/src/widgets/dance_list_tile.dart index 9dc39ba7..9c6c8200 100644 --- a/app/lib/src/widgets/dance_list_tile.dart +++ b/app/lib/src/widgets/dance_list_tile.dart @@ -33,6 +33,7 @@ class DanceListTile extends StatelessWidget { this.onDelete, this.onDuplicate, this.onAddToProgram, + this.onTagTap, }) : assert( !selectionMode || selected == selectedForBatch, 'In selection mode the row highlight (selected) must match the ' @@ -72,6 +73,12 @@ class DanceListTile extends StatelessWidget { /// [selectionMode]) the ⋮ menu exposes an "Add to program" action. final VoidCallback? onAddToProgram; + /// Called with a tag's id when its chip is tapped, to filter the Collection + /// to that tag (issue #414). When null (e.g. the Programs dance picker) the + /// tag chips stay non-interactive. Ignored while in [selectionMode], where + /// the whole row drives batch selection. + final void Function(String tagId)? onTagTap; + @override Widget build(BuildContext context) { final dance = entry.dance; @@ -184,13 +191,24 @@ class DanceListTile extends StatelessWidget { visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - for (final tag in entry.tagNames) - Chip( - avatar: const Icon(Icons.label_outline, size: 16), - label: Text(tag), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), + for (final tag in entry.tags) + if (onTagTap != null && !selectionMode) + ActionChip( + key: ValueKey('tag-filter-chip-${tag.id}'), + avatar: const Icon(Icons.label_outline, size: 16), + label: Text(tag.name), + tooltip: 'Show dances tagged “${tag.name}”', + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () => onTagTap!(tag.id), + ) + else + Chip( + avatar: const Icon(Icons.label_outline, size: 16), + label: Text(tag.name), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), for (final field in entry.listCustomFields) Chip( label: Text(field), diff --git a/app/test/tag_filter_navigation_test.dart b/app/test/tag_filter_navigation_test.dart new file mode 100644 index 00000000..39e011d1 --- /dev/null +++ b/app/test/tag_filter_navigation_test.dart @@ -0,0 +1,285 @@ +import 'package:compendium_core/compendium_core.dart'; +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:compendium_app/src/data/active_dialect_scope.dart'; +import 'package:compendium_app/src/data/app_theme_scope.dart'; +import 'package:compendium_app/src/data/collection_filter_scope.dart'; +import 'package:compendium_app/src/data/custom_themes_controller.dart'; +import 'package:compendium_app/src/data/custom_themes_scope.dart'; +import 'package:compendium_app/src/data/repositories_scope.dart'; +import 'package:compendium_app/src/data/require_performed_for_history_scope.dart'; +import 'package:compendium_app/src/screens/app_shell.dart'; +import 'package:compendium_app/src/screens/dance_detail_screen.dart'; +import 'package:compendium_app/src/widgets/dance_list_tile.dart'; +import 'package:compendium_app/src/update/update_controller.dart'; +import 'package:compendium_app/src/update/update_scope.dart'; +import 'package:compendium_app/src/update/update_service.dart'; + +import 'support/test_repositories.dart'; + +Dance _dance({ + required String id, + required String title, + List tagIds = const [], +}) => Dance( + id: id, + title: title, + authorIds: const [], + tagIds: tagIds, + form: DanceForm.contra, + formation: const Formation(FormationShape.dupleImproper), + status: DanceStatus.active, + figures: const [], + customFields: const [], + hook: '', + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 1), +); + +Future _pumpShell( + WidgetTester tester, + CompendiumRepositories repos, { + required Size size, +}) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dialect = ValueNotifier(Dialect.larksRobins); + addTearDown(dialect.dispose); + final theme = ValueNotifier(AppThemeSelection.system); + addTearDown(theme.dispose); + final requirePerformed = ValueNotifier(false); + addTearDown(requirePerformed.dispose); + final customThemes = CustomThemesController(repos.settings); + await customThemes.load(); + addTearDown(customThemes.dispose); + final updateController = UpdateController( + repos.settings, + service: UpdateService(fetcher: (_, {client}) async => null), + ); + addTearDown(updateController.dispose); + final filterController = CollectionFilterController(); + addTearDown(filterController.dispose); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => RepositoriesScope( + repositories: repos, + child: UpdateScope( + controller: updateController, + child: AppThemeScope( + notifier: theme, + child: CustomThemesScope( + controller: customThemes, + child: ActiveDialectScope( + notifier: dialect, + child: RequirePerformedForHistoryScope( + notifier: requirePerformed, + // Wired above the root navigator (as in main.dart) so a tag + // chip on a pushed detail route can reach it. + child: CollectionFilterScope( + controller: filterController, + child: child!, + ), + ), + ), + ), + ), + ), + ), + home: const AppShell(), + ), + ); + await tester.pumpAndSettle(); +} + +List _listedTitles(WidgetTester tester) => tester + .widgetList(find.byType(DanceListTile)) + .map((t) => t.entry.title) + .toList(); + +Future _openDetail(WidgetTester tester, String title) async { + await tester.tap(find.text(title).first); + await tester.pumpAndSettle(); +} + +/// The tag chip *inside the detail view* (scoped so it never matches the +/// still-mounted list rows, whose chips share the same id-based key). +Finder _detailTagChip(String tagId) => find.descendant( + of: find.byType(DanceDetailScreen), + matching: find.byKey(ValueKey('tag-filter-chip-$tagId')), +); + +Future _tapDetailTagChip(WidgetTester tester, String tagId) async { + final chip = _detailTagChip(tagId); + await tester.ensureVisible(chip); + await tester.pumpAndSettle(); + await tester.tap(chip); + await tester.pumpAndSettle(); +} + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + setUp(rootBundle.clear); + + testWidgets( + 'narrow: tapping a tag on a pushed dance detail pops back and filters ' + 'the Collection to that tag', + (tester) async { + final repos = openTestRepositories(); + await repos.tags.upsert(Tag(id: 't1', name: 'smooth')); + await repos.tags.upsert(Tag(id: 't2', name: 'energetic')); + await repos.dances.create( + _dance(id: 'd1', title: 'Smooth One', tagIds: const ['t1']), + ); + await repos.dances.create( + _dance(id: 'd2', title: 'Smooth Two', tagIds: const ['t1']), + ); + await repos.dances.create( + _dance(id: 'd3', title: 'Energetic Three', tagIds: const ['t2']), + ); + + await _pumpShell(tester, repos, size: const Size(500, 1400)); + + expect(_listedTitles(tester).toSet(), { + 'Smooth One', + 'Smooth Two', + 'Energetic Three', + }); + + await _openDetail(tester, 'Smooth One'); + expect(find.byType(DanceDetailScreen), findsOneWidget); + await _tapDetailTagChip(tester, 't1'); + + // Popped back to the Collection list, filtered to 'smooth'. + expect(find.byType(DanceDetailScreen), findsNothing); + expect(_listedTitles(tester).toSet(), {'Smooth One', 'Smooth Two'}); + expect(find.text('Filters (1 active)'), findsOneWidget); + }, + ); + + testWidgets( + 'wide: tapping a tag in the embedded detail pane filters the list pane', + (tester) async { + final repos = openTestRepositories(); + await repos.tags.upsert(Tag(id: 't1', name: 'smooth')); + await repos.tags.upsert(Tag(id: 't2', name: 'energetic')); + await repos.dances.create( + _dance(id: 'd1', title: 'Smooth One', tagIds: const ['t1']), + ); + await repos.dances.create( + _dance(id: 'd2', title: 'Smooth Two', tagIds: const ['t1']), + ); + await repos.dances.create( + _dance(id: 'd3', title: 'Energetic Three', tagIds: const ['t2']), + ); + + await _pumpShell(tester, repos, size: const Size(1300, 1400)); + + await _openDetail(tester, 'Smooth One'); + expect(find.byType(DanceDetailScreen), findsOneWidget); + await _tapDetailTagChip(tester, 't1'); + + // List pane is filtered; the detail pane still shows the selected dance. + expect(_listedTitles(tester).toSet(), {'Smooth One', 'Smooth Two'}); + expect(find.text('Filters (1 active)'), findsOneWidget); + }, + ); + + testWidgets('the tag filter is clearable, restoring the full list', ( + tester, + ) async { + final repos = openTestRepositories(); + await repos.tags.upsert(Tag(id: 't1', name: 'smooth')); + await repos.dances.create( + _dance(id: 'd1', title: 'Smooth One', tagIds: const ['t1']), + ); + await repos.dances.create(_dance(id: 'd2', title: 'Untagged Two')); + + await _pumpShell(tester, repos, size: const Size(500, 1400)); + + await _openDetail(tester, 'Smooth One'); + await _tapDetailTagChip(tester, 't1'); + expect(_listedTitles(tester), ['Smooth One']); + + // Open the Filters panel and clear — the full list returns. + await tester.tap(find.byKey(const ValueKey('filters-panel'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('clear-filters'))); + await tester.pumpAndSettle(); + expect(_listedTitles(tester).toSet(), {'Smooth One', 'Untagged Two'}); + }); + + testWidgets('filtering by a tag with no other dances shows only that dance', ( + tester, + ) async { + final repos = openTestRepositories(); + await repos.tags.upsert(Tag(id: 't1', name: 'unique')); + await repos.dances.create( + _dance(id: 'd1', title: 'Lonely Dance', tagIds: const ['t1']), + ); + await repos.dances.create(_dance(id: 'd2', title: 'Other Dance')); + + await _pumpShell(tester, repos, size: const Size(500, 1400)); + + await _openDetail(tester, 'Lonely Dance'); + await _tapDetailTagChip(tester, 't1'); + + expect(_listedTitles(tester), ['Lonely Dance']); + expect(find.text('1 dance'), findsOneWidget); + }); + + testWidgets( + 'a tag whose name has special characters filters by id, not by text', + (tester) async { + const trickyName = "O'Neil & 50% \"quote\""; + final repos = openTestRepositories(); + await repos.tags.upsert(Tag(id: 't1', name: trickyName)); + await repos.dances.create( + _dance(id: 'd1', title: 'Tagged Dance', tagIds: const ['t1']), + ); + await repos.dances.create(_dance(id: 'd2', title: 'Plain Dance')); + + await _pumpShell(tester, repos, size: const Size(500, 1400)); + + await _openDetail(tester, 'Tagged Dance'); + // The chip renders the special-character name literally. + expect( + find.descendant( + of: find.byType(DanceDetailScreen), + matching: find.text(trickyName), + ), + findsOneWidget, + ); + await _tapDetailTagChip(tester, 't1'); + + expect(_listedTitles(tester), ['Tagged Dance']); + }, + ); + + testWidgets('tapping a tag chip on a list row filters in place', ( + tester, + ) async { + final repos = openTestRepositories(); + await repos.tags.upsert(Tag(id: 't1', name: 'smooth')); + await repos.dances.create( + _dance(id: 'd1', title: 'Smooth One', tagIds: const ['t1']), + ); + await repos.dances.create(_dance(id: 'd2', title: 'Untagged Two')); + + // Wide layout keeps the list pane visible while filtering. + await _pumpShell(tester, repos, size: const Size(1300, 1400)); + + expect(_listedTitles(tester).toSet(), {'Smooth One', 'Untagged Two'}); + final rowChip = find.descendant( + of: find.byType(DanceListTile), + matching: find.byKey(const ValueKey('tag-filter-chip-t1')), + ); + await tester.tap(rowChip.first); + await tester.pumpAndSettle(); + expect(_listedTitles(tester), ['Smooth One']); + }); +} From 2357d138fe07dd2f4eb127a3b82d043356accd58 Mon Sep 17 00:00:00 2001 From: Isaac Banner Date: Mon, 20 Jul 2026 17:55:08 -0700 Subject: [PATCH 2/2] fix(collection): invalidate in-flight online search when tag filter exits online mode _applyExternalTagFilter turns off online mode but an already-awaiting _runOnlineSearch() future could still complete and repopulate _onlineResults/_onlineError. Bump _onlineSeq so the existing seq guard ignores those late responses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- app/lib/src/screens/dance_list_screen.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/lib/src/screens/dance_list_screen.dart b/app/lib/src/screens/dance_list_screen.dart index 02f7cddb..847aedbd 100644 --- a/app/lib/src/screens/dance_list_screen.dart +++ b/app/lib/src/screens/dance_list_screen.dart @@ -333,6 +333,9 @@ class _DanceListScreenState extends State { _advancedRoot.kind = GroupKind.all; _advancedEnabled = false; _onlineEnabled = false; + // Invalidate any in-flight online search so a late response can't + // repopulate _onlineResults/_onlineError after we've left online mode. + _onlineSeq++; _onlineResults = const []; _onlineError = null; _onlineSearching = false;