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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -192,6 +193,12 @@ class _CompendiumAppState extends State<CompendiumApp> {
/// Collection list re-boots without a relaunch. Exposed via
/// [CollectionRefreshScope].
final ValueNotifier<int> _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;
Expand Down Expand Up @@ -549,6 +556,7 @@ class _CompendiumAppState extends State<CompendiumApp> {
_setListColorCodingNotifier.dispose();
_dateFormatNotifier.dispose();
_collectionRefreshNotifier.dispose();
_collectionFilterController.dispose();
_customThemes.dispose();
_formationColors.dispose();
_dialectLibrary.removeListener(_syncActiveDialect);
Expand Down Expand Up @@ -707,7 +715,11 @@ class _CompendiumAppState extends State<CompendiumApp> {
child: CollectionRefreshScope(
revision:
_collectionRefreshNotifier,
child: child!,
child: CollectionFilterScope(
controller:
_collectionFilterController,
child: child!,
),
),
),
),
Expand Down
75 changes: 75 additions & 0 deletions app/lib/src/data/collection_filter_scope.dart
Original file line number Diff line number Diff line change
@@ -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<CollectionFilterController> {
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<CollectionFilterScope>()
?.notifier;
}
7 changes: 7 additions & 0 deletions app/lib/src/models/dance_list_entry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +18,12 @@ class DanceListEntry {
final List<String> authorNames;
final List<String> 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<String> listCustomFields;
Expand Down
45 changes: 45 additions & 0 deletions app/lib/src/screens/app_shell.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,6 +38,50 @@ class AppShell extends StatefulWidget {
class _AppShellState extends State<AppShell> {
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
Expand Down
41 changes: 31 additions & 10 deletions app/lib/src/screens/dance_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -700,17 +701,37 @@ class _DanceDetailScreenState extends State<DanceDetailScreen> {
),
),
),
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),
Expand Down
64 changes: 64 additions & 0 deletions app/lib/src/screens/dance_list_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -172,6 +173,15 @@ class _DanceListScreenState extends State<DanceListScreen> {
/// from Settings) bumps it. Tracked so listeners are swapped correctly.
ValueListenable<int>? _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;

Expand Down Expand Up @@ -258,6 +268,20 @@ class _DanceListScreenState extends State<DanceListScreen> {
_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
Expand All @@ -282,10 +306,49 @@ class _DanceListScreenState extends State<DanceListScreen> {
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();
Expand Down Expand Up @@ -1572,6 +1635,7 @@ class _DanceListScreenState extends State<DanceListScreen> {
}
},
onDuplicate: () => _duplicateFromList(entry.dance.id),
onTagTap: _applyExternalTagFilter,
onAddToProgram: () => showAddToProgramSheet(
context,
repositories: _repos,
Expand Down
4 changes: 4 additions & 0 deletions app/lib/src/search/collection_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading