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..847aedbd 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,49 @@ 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; + // 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; + _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 +1635,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']); + }); +}