Skip to content

Commit af30dc0

Browse files
feat(drinks): multi-select categories and grouped style filter (#506)
* feat(drinks): support multi-select category filtering Category filtering was single-select (String?), so users could not view beer + international-beer + low-no together even though those three categories make up 76% of a typical festival. Switch DrinkFilterController/Service and BeerProvider to a Set<String>-based category filter (OR logic across categories, matching the existing style-filter pattern) and convert CategoryFilterSheet from single-select radios to multi-select checkboxes that stay open across taps. Toggling a category now prunes (rather than clears) the selected styles, keeping only styles still present under the new category scope, since an unconditional clear was too destructive once several categories can be active at once. Analytics keeps logCategoryFilter's existing String? signature; the provider now passes a canonical sorted, comma-joined value (or null when cleared) so the logged value is independent of selection order. Fixes #319 * feat(drinks): group styles by category in the style filter sheet With no category selected, the style list was one flat alphabetical run mixing styles from unrelated categories (e.g. wine and perry styles interleaved with beer styles). Add DrinkFilterController.stylesByCategory, a grouped view of the same style facet scope the facet-scoping rule already defines, and render a category header above each group in StyleFilterSheet. A lone group (the common case once a single category is selected) renders flat with no header. Headers reuse the existing "Allergen-free" label treatment and carry Semantics(header: true) for screen-reader section navigation. A selected style outside the current scope is still grouped, under its category in the full source, preserving the facet-scoping invariant that an active filter is never hidden. Fixes #318 * fix(a11y): announce formatted category names in the filter bar The category filter button renders formatted names ('International Beer') but its semanticLabel joined the raw category ids, so a screen reader announced 'international-beer'. Sighted and screen-reader users now get the same names, sorted so the announcement is deterministic (a Set has no defined order). Adds semantic tests pinning the label in all three states. * test(drinks): cover the empty-state Clear Filters button The empty-state button is the only way out of a filter combination that matches nothing without reopening the category sheet, and this branch changed its callback from setCategory(null) to clearCategories() with no test exercising it. Asserts the button appears when a category selection yields no drinks, clears the selection when tapped, and disappears afterwards. * fix(a11y): correct filter sheet count grammar and clear-all label Two screen-reader wording fixes surfaced in review: Category and style filter rows announced '1 drinks'. They now use the count == 1 ? 'drink' : 'drinks' idiom already used in festival_header and my_festival_screen. The empty-state button clears every selected category since this branch made categories multi-select, but announced 'Clear category filter' (singular) and hinted 'show all drinks' — a promise it cannot keep when a search or style filter is also active. * fix(drinks): left-align style filter category headers The headers were the only children narrower than the sheet in a Column that defaults to centre alignment, so they rendered centred instead of sitting above their group. Every other Column in this file already sets CrossAxisAlignment.start. Found by driving a local release web build in a headless browser; the widget tests passed because none of them asserted horizontal position. The header test now pins it. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 114e970 commit af30dc0

11 files changed

Lines changed: 873 additions & 189 deletions

lib/domain/controllers/drink_filter_controller.dart

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:collection/collection.dart';
2+
13
import '../../models/models.dart';
24
import '../../utils/string_comparison_helper.dart';
35
import '../models/models.dart';
@@ -57,7 +59,7 @@ class DrinkFilterController {
5759
List<Drink> _source = [];
5860
List<Drink> _filtered = [];
5961

60-
String? _selectedCategory;
62+
Set<String> _selectedCategories = {};
6163
Set<String> _selectedStyles = {};
6264
DrinkSort _currentSort = DrinkSort.nameAsc;
6365
String _searchQuery = '';
@@ -67,7 +69,7 @@ class DrinkFilterController {
6769

6870
// --- Criteria getters ---
6971

70-
String? get selectedCategory => _selectedCategory;
72+
Set<String> get selectedCategories => Set.unmodifiable(_selectedCategories);
7173
Set<String> get selectedStyles => _selectedStyles;
7274
DrinkSort get currentSort => _currentSort;
7375
String get searchQuery => _searchQuery;
@@ -86,13 +88,11 @@ class DrinkFilterController {
8688
List<Drink> get filteredDrinks => _filtered;
8789

8890
/// Unique categories present in scope (see class doc), sorted naturally.
89-
/// The [selectedCategory], if any, is always included even if its scoped
91+
/// Every [selectedCategories] entry is always included even if its scoped
9092
/// count is 0 (invariant 1).
9193
List<String> get availableCategories {
92-
final categories = _scopeFor(
93-
_Facet.category,
94-
).map((d) => d.category).toSet();
95-
if (_selectedCategory != null) categories.add(_selectedCategory!);
94+
final categories = _scopeFor(_Facet.category).map((d) => d.category).toSet()
95+
..addAll(_selectedCategories);
9696
return categories.toList()..sort();
9797
}
9898

@@ -112,15 +112,64 @@ class DrinkFilterController {
112112
return styles.toList()..sort(StringComparisonHelper.compareCaseInsensitive);
113113
}
114114

115-
/// Drink count per category, scoped per the class doc. A [selectedCategory]
116-
/// with no matches in scope is still present, mapped to 0 (invariant 1).
115+
/// [availableStyles] grouped by category, for presentation as headed
116+
/// sections in the style filter sheet (issue #318 — a flat, alphabetical
117+
/// style list mixes styles from unrelated categories, e.g. wine and perry
118+
/// styles interleaved with beer styles). Built from the same style facet
119+
/// scope [_scopeFor] already defines — this is not a second scoping rule,
120+
/// just a different shape of the same scoped data.
121+
///
122+
/// Keys are categories sorted naturally; values are that category's
123+
/// styles, sorted case-insensitively via
124+
/// [StringComparisonHelper.compareCaseInsensitive] — matching
125+
/// [availableStyles]'s own ordering. All ordering lives here, not in the
126+
/// UI.
127+
///
128+
/// Invariant 1 still applies: a [selectedStyles] entry absent from scope
129+
/// is still included, grouped under the category it carries in the full,
130+
/// unfiltered [_source] (falling back to the first source drink with that
131+
/// style, since scope has none to offer).
132+
///
133+
/// Counts are deliberately not part of this view — read them from
134+
/// [styleCountsMap], which is scoped identically (per style name, not per
135+
/// category), so the number shown next to a style always matches what
136+
/// ticking it actually yields. A style name occurring under two
137+
/// categories would appear in both groups sharing that one count; this is
138+
/// verified to be zero occurrences in current festival data, but nothing
139+
/// here assumes it can't happen.
140+
Map<String, List<String>> get stylesByCategory {
141+
final byCategory = <String, Set<String>>{};
142+
for (final drink in _scopeFor(_Facet.style)) {
143+
if (drink.style == null || drink.style!.isEmpty) continue;
144+
byCategory.putIfAbsent(drink.category, () => {}).add(drink.style!);
145+
}
146+
for (final style in _selectedStyles) {
147+
if (byCategory.values.any((styles) => styles.contains(style))) {
148+
continue;
149+
}
150+
final sourceDrink = _source.firstWhereOrNull((d) => d.style == style);
151+
if (sourceDrink != null) {
152+
byCategory.putIfAbsent(sourceDrink.category, () => {}).add(style);
153+
}
154+
}
155+
final sortedCategories = byCategory.keys.toList()..sort();
156+
return {
157+
for (final category in sortedCategories)
158+
category: byCategory[category]!.toList()
159+
..sort(StringComparisonHelper.compareCaseInsensitive),
160+
};
161+
}
162+
163+
/// Drink count per category, scoped per the class doc. Every entry of
164+
/// [selectedCategories] with no matches in scope is still present, mapped
165+
/// to 0 (invariant 1).
117166
Map<String, int> get categoryCountsMap {
118167
final counts = <String, int>{};
119168
for (final drink in _scopeFor(_Facet.category)) {
120169
counts[drink.category] = (counts[drink.category] ?? 0) + 1;
121170
}
122-
if (_selectedCategory != null) {
123-
counts.putIfAbsent(_selectedCategory!, () => 0);
171+
for (final category in _selectedCategories) {
172+
counts.putIfAbsent(category, () => 0);
124173
}
125174
return counts;
126175
}
@@ -170,7 +219,7 @@ class DrinkFilterController {
170219
void recompute() {
171220
final filtered = _filterService.filterDrinks(
172221
_source,
173-
category: _selectedCategory,
222+
categories: _selectedCategories,
174223
styles: _selectedStyles,
175224
favoritesOnly: _showFavoritesOnly,
176225
visibilityFilters: _visibilityFilters,
@@ -182,16 +231,49 @@ class DrinkFilterController {
182231

183232
// --- Mutators (synchronous, no side effects) ---
184233

185-
/// Set the category filter. Clears any active style filter, since styles are
186-
/// category-dependent.
187-
void setCategory(String? category) {
188-
_selectedCategory = category;
189-
if (_selectedStyles.isNotEmpty) {
190-
_selectedStyles = {};
234+
/// Toggle a single category in the multi-select category filter.
235+
///
236+
/// Prunes (rather than clears) the style selection: a style survives the
237+
/// toggle only if it is still present in the style facet's scope under the
238+
/// *new* category selection (see [_scopeFor] / [availableStyles]). Under
239+
/// single-select this used to be an unconditional clear, but that is too
240+
/// destructive for multi-select — e.g. adding "perry" to an existing
241+
/// "cider" selection would otherwise wipe a cider style the user just
242+
/// picked, even though it's still relevant to the combined selection.
243+
void toggleCategory(String category) {
244+
if (_selectedCategories.contains(category)) {
245+
_selectedCategories = Set.from(_selectedCategories)..remove(category);
246+
} else {
247+
_selectedCategories = Set.from(_selectedCategories)..add(category);
191248
}
249+
_pruneStylesToScope();
250+
recompute();
251+
}
252+
253+
/// Clear all selected categories.
254+
void clearCategories() {
255+
_selectedCategories = {};
256+
_pruneStylesToScope();
192257
recompute();
193258
}
194259

260+
/// Drop any selected style no longer present in the style facet's current
261+
/// scope (i.e. under the just-changed category selection). Recomputes
262+
/// scope directly against `_selectedStyles` rather than [availableStyles]
263+
/// so it isn't affected by that getter's own invariant-1 re-inclusion of
264+
/// already-selected styles.
265+
void _pruneStylesToScope() {
266+
if (_selectedStyles.isEmpty) return;
267+
final scopedStyles = _scopeFor(_Facet.style)
268+
.where((d) => d.style != null && d.style!.isNotEmpty)
269+
.map((d) => d.style!)
270+
.toSet();
271+
final pruned = _selectedStyles.where(scopedStyles.contains).toSet();
272+
if (pruned.length != _selectedStyles.length) {
273+
_selectedStyles = pruned;
274+
}
275+
}
276+
195277
/// Toggle a single style in the multi-select style filter.
196278
void toggleStyle(String style) {
197279
if (_selectedStyles.contains(style)) {
@@ -265,7 +347,7 @@ class DrinkFilterController {
265347
/// festivals). Sort, visibility, and allergen preferences are intentionally
266348
/// preserved.
267349
void clearCategoryStyleSearch() {
268-
_selectedCategory = null;
350+
_selectedCategories = {};
269351
_selectedStyles = {};
270352
_searchQuery = '';
271353
recompute();
@@ -291,7 +373,7 @@ class DrinkFilterController {
291373
/// applied here (see class doc).
292374
Iterable<Drink> _scopeFor(_Facet facet) => _filterService.filterDrinks(
293375
_source,
294-
category: facet == _Facet.category ? null : _selectedCategory,
376+
categories: facet == _Facet.category ? const {} : _selectedCategories,
295377
styles: facet == _Facet.style ? const {} : _selectedStyles,
296378
favoritesOnly: _showFavoritesOnly,
297379
visibilityFilters: _visibilityFilters,

lib/domain/services/drink_filter_service.dart

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ class DrinkFilterService {
1010
/// Shared source of truth for which fields free-text search covers.
1111
static const SearchMatchService _searchMatcher = SearchMatchService();
1212

13-
/// Filter drinks by category
13+
/// Filter drinks by categories (multi-select with OR logic)
1414
///
15-
/// Returns all drinks if [category] is null
15+
/// Returns all drinks if [categories] is empty
1616
/// Uses lazy evaluation - call .toList() to materialize
17-
Iterable<Drink> filterByCategory(Iterable<Drink> drinks, String? category) {
18-
if (category == null) return drinks;
19-
return drinks.where((d) => d.category == category);
17+
Iterable<Drink> filterByCategories(
18+
Iterable<Drink> drinks,
19+
Set<String> categories,
20+
) {
21+
if (categories.isEmpty) return drinks;
22+
return drinks.where((d) => categories.contains(d.category));
2023
}
2124

2225
/// Filter drinks by styles (multi-select with OR logic)
@@ -138,14 +141,14 @@ class DrinkFilterService {
138141
/// chain materialises once at the end.
139142
List<Drink> filterDrinks(
140143
List<Drink> drinks, {
141-
String? category,
144+
Set<String>? categories,
142145
Set<String>? styles,
143146
bool favoritesOnly = false,
144147
Set<DrinkVisibilityFilter> visibilityFilters = const {},
145148
Set<String> excludedAllergens = const {},
146149
String searchQuery = '',
147150
}) {
148-
Iterable<Drink> result = filterByCategory(drinks, category);
151+
Iterable<Drink> result = filterByCategories(drinks, categories ?? const {});
149152
result = filterByStyles(result, styles ?? const {});
150153
result = filterByFavorites(result, favoritesOnly: favoritesOnly);
151154
result = filterByAvailability(

lib/providers/beer_provider.dart

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ class BeerProvider extends ChangeNotifier {
118118
/// Non-null when a background refresh failed but cached drinks remain shown.
119119
String? get refreshNotice => _refreshNotice;
120120
String? get festivalsError => _festivalsError;
121-
String? get selectedCategory => _filter.selectedCategory;
121+
Set<String> get selectedCategories => _filter.selectedCategories;
122122
Set<String> get selectedStyles => _filter.selectedStyles;
123123
DrinkSort get currentSort => _filter.currentSort;
124124
String get searchQuery => _filter.searchQuery;
@@ -152,6 +152,10 @@ class BeerProvider extends ChangeNotifier {
152152
/// Get unique styles from loaded drinks (filtered by category if selected)
153153
List<String> get availableStyles => _filter.availableStyles;
154154

155+
/// Get [availableStyles] grouped by category, for the headed style filter
156+
/// sheet sections. See [DrinkFilterController.stylesByCategory].
157+
Map<String, List<String>> get stylesByCategory => _filter.stylesByCategory;
158+
155159
/// Get drink count by category
156160
Map<String, int> get categoryCountsMap => _filter.categoryCountsMap;
157161

@@ -657,16 +661,34 @@ class BeerProvider extends ChangeNotifier {
657661
}
658662
}
659663

660-
/// Set category filter
661-
void setCategory(String? category) {
662-
// The controller clears the style filter when the category changes, since
663-
// styles are category-dependent.
664-
_filter.setCategory(category);
664+
/// Toggle a category filter (supports multiple category selection).
665+
///
666+
/// The controller prunes any selected style no longer in scope under the
667+
/// new category selection, since styles are category-dependent.
668+
void toggleCategory(String category) {
669+
_filter.toggleCategory(category);
665670
notifyListeners();
666-
// Log analytics event (fire and forget)
667-
unawaited(_analyticsService.logCategoryFilter(category));
671+
// Log analytics event (fire and forget). Canonical value: null when the
672+
// selection is empty, otherwise the selected categories sorted and
673+
// joined with ',' — logCategoryFilter's signature is unchanged, so this
674+
// keeps a single, order-independent value per selection.
675+
unawaited(_analyticsService.logCategoryFilter(_canonicalCategoryFilter));
668676
}
669677

678+
/// Clear all category filters.
679+
void clearCategories() {
680+
_filter.clearCategories();
681+
notifyListeners();
682+
unawaited(_analyticsService.logCategoryFilter(_canonicalCategoryFilter));
683+
}
684+
685+
/// Canonical analytics value for the current category selection: `null`
686+
/// when empty, otherwise the selected categories sorted and joined with
687+
/// ',' so the logged value doesn't depend on selection order.
688+
String? get _canonicalCategoryFilter => _filter.selectedCategories.isEmpty
689+
? null
690+
: (_filter.selectedCategories.toList()..sort()).join(',');
691+
670692
/// Toggle a style filter (supports multiple style selection)
671693
void toggleStyle(String style) {
672694
_filter.toggleStyle(style);

lib/screens/drinks_screen.dart

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,20 +143,32 @@ class _DrinksScreenState extends State<DrinksScreen> {
143143
: provider.selectedStyles.length == 1
144144
? provider.selectedStyles.first
145145
: '${provider.selectedStyles.length} styles';
146+
// Formatted and sorted so the screen reader announces the same names a
147+
// sighted user sees, in a deterministic order (a Set has none).
148+
final formattedCategories =
149+
provider.selectedCategories
150+
.map(BeverageTypeHelper.formatBeverageType)
151+
.toList()
152+
..sort();
153+
final categoryLabel = provider.selectedCategories.isEmpty
154+
? 'Category'
155+
: formattedCategories.length == 1
156+
? formattedCategories.first
157+
: '${formattedCategories.length} categories';
146158

147159
return Container(
148160
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
149161
child: Row(
150162
children: [
151163
Expanded(
152164
child: FilterButton(
153-
label: provider.selectedCategory ?? 'Category',
154-
semanticLabel: provider.selectedCategory != null
155-
? 'Filter by category: ${provider.selectedCategory}'
156-
: 'Filter by category',
165+
label: categoryLabel,
166+
semanticLabel: formattedCategories.isEmpty
167+
? 'Filter by category'
168+
: 'Filter by category: ${formattedCategories.join(', ')}',
157169
icon: Icons.filter_list,
158170
onPressed: () => showCategoryFilter(context),
159-
isActive: provider.selectedCategory != null,
171+
isActive: provider.selectedCategories.isNotEmpty,
160172
),
161173
),
162174
if (hasStyleFilter) ...[
@@ -344,15 +356,15 @@ class _DrinksScreenState extends State<DrinksScreen> {
344356
),
345357
const SizedBox(height: 8),
346358
const Text('Try adjusting your filters'),
347-
if (provider.selectedCategory != null) ...[
359+
if (provider.selectedCategories.isNotEmpty) ...[
348360
const SizedBox(height: 16),
349361
Semantics(
350-
label: 'Clear category filter',
351-
hint: 'Double tap to show all drinks',
362+
label: 'Clear all category filters',
363+
hint: 'Double tap to show every category',
352364
button: true,
353365
excludeSemantics: true,
354366
child: OutlinedButton(
355-
onPressed: () => provider.setCategory(null),
367+
onPressed: () => provider.clearCategories(),
356368
child: const Text('Clear Filters'),
357369
),
358370
),

0 commit comments

Comments
 (0)