Skip to content
Merged
14 changes: 14 additions & 0 deletions lib/domain/models/drink_visibility_filter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// Visibility filter options for the drinks list.
///
/// Each value represents a filter that can be independently toggled.
/// Multiple filters are applied with AND logic (all conditions must be met).
enum DrinkVisibilityFilter {
/// Hide drinks that are sold out or not yet available
availableOnly,

/// Hide drinks the user has already tasted
notTasted,

/// Show only drinks marked as vegan
veganOnly,
}
1 change: 1 addition & 0 deletions lib/domain/models/models.dart
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export 'drink_sort.dart';
export 'drink_visibility_filter.dart';
72 changes: 61 additions & 11 deletions lib/domain/services/drink_filter_service.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import '../../models/models.dart';
import '../models/models.dart';

/// Service for filtering drinks based on various criteria
///
Expand Down Expand Up @@ -56,6 +57,48 @@ class DrinkFilterService {
d.availabilityStatus != AvailabilityStatus.notYetAvailable);
}

/// Filter drinks to hide ones already tasted
///
/// Returns all drinks if [notTastedOnly] is false
/// Uses lazy evaluation - call .toList() to materialize
Iterable<Drink> filterByNotTasted(
Iterable<Drink> drinks,
bool notTastedOnly,
) {
if (!notTastedOnly) return drinks;
return drinks.where((d) => !d.isTasted);
}

/// Filter drinks to show only vegan ones
///
/// A drink is included if its [Drink.isVegan] flag is explicitly true.
/// Drinks with null (unknown) vegan status are excluded.
/// Returns all drinks if [veganOnly] is false
/// Uses lazy evaluation - call .toList() to materialize
Iterable<Drink> filterByVegan(
Iterable<Drink> drinks,
bool veganOnly,
) {
if (!veganOnly) return drinks;
return drinks.where((d) => d.isVegan == true);
}

/// Filter drinks to exclude those containing any of the specified allergens
///
/// A drink is excluded if any of the [excludedAllergens] keys maps to a
/// non-zero value in the drink's allergens map. A missing key or value of 0
/// means the allergen is absent — the drink passes.
/// Returns all drinks when [excludedAllergens] is empty.
/// Uses lazy evaluation - call .toList() to materialize
Iterable<Drink> filterByExcludedAllergens(
Iterable<Drink> drinks,
Set<String> excludedAllergens,
) {
if (excludedAllergens.isEmpty) return drinks;
return drinks.where((d) =>
excludedAllergens.every((a) => (d.allergens[a] ?? 0) == 0));
}

/// Filter drinks by search query
///
/// Searches across drink name, brewery name, style, and notes
Expand All @@ -78,12 +121,13 @@ class DrinkFilterService {

/// Filter drinks with multiple criteria
///
/// Optimized method that applies all filters in a single pass:
/// Applies filters in sequence:
/// 1. Category filter
/// 2. Style filter
/// 3. Favorites filter
/// 4. Availability filter
/// 5. Search filter
/// 4. Visibility filters (availability, not-tasted, vegan)
/// 5. Allergen exclusions
/// 6. Search filter
///
/// Each filter is only applied if its criteria is active.
/// Uses Iterable chaining to avoid intermediate list allocations.
Expand All @@ -92,34 +136,41 @@ class DrinkFilterService {
String? category,
Set<String>? styles,
bool favoritesOnly = false,
bool hideUnavailable = false,
Set<DrinkVisibilityFilter> visibilityFilters = const {},
Set<String> excludedAllergens = const {},
Comment on lines +139 to +140
String searchQuery = '',
}) {
Iterable<Drink> result = drinks;

// Apply category filter
if (category != null) {
result = result.where((d) => d.category == category);
}

// Apply styles filter
if (styles != null && styles.isNotEmpty) {
result = result.where((d) => d.style != null && styles.contains(d.style));
}

// Apply favorites filter
if (favoritesOnly) {
result = result.where((d) => d.isFavorite);
}

// Apply availability filter
if (hideUnavailable) {
if (visibilityFilters.contains(DrinkVisibilityFilter.availableOnly)) {
result = result.where((d) =>
d.availabilityStatus != AvailabilityStatus.out &&
d.availabilityStatus != AvailabilityStatus.notYetAvailable);
}
if (visibilityFilters.contains(DrinkVisibilityFilter.notTasted)) {
result = result.where((d) => !d.isTasted);
}
if (visibilityFilters.contains(DrinkVisibilityFilter.veganOnly)) {
result = result.where((d) => d.isVegan == true);
}

if (excludedAllergens.isNotEmpty) {
result = result.where((d) =>
excludedAllergens.every((a) => (d.allergens[a] ?? 0) == 0));
}

// Apply search filter
if (searchQuery.isNotEmpty) {
final lowerQuery = searchQuery.toLowerCase();
result = result.where((d) {
Expand All @@ -130,7 +181,6 @@ class DrinkFilterService {
});
}

// Materialize the result only once at the end
return result.toList();
}
}
21 changes: 13 additions & 8 deletions lib/models/drink.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ class Product {
final String? notes;
final String? statusText;
final String? bar;
final bool? vegan;
final Map<String, int> allergens;
final bool? isVegan;

const Product({
required this.id,
Expand All @@ -75,8 +75,8 @@ class Product {
this.notes,
this.statusText,
this.bar,
this.vegan,
this.allergens = const {},
this.isVegan,
});

factory Product.fromJson(Map<String, dynamic> json) {
Expand All @@ -93,10 +93,10 @@ class Product {
// Parse allergens robustly - values are typically int (1 = present) but may
// also be bool or other numeric types. Unknown types are skipped as they
// don't represent a valid allergen flag.
final allergensJson = json['allergens'] as Map<String, dynamic>?;
final allergensRaw = json['allergens'];
final allergens = <String, int>{};
if (allergensJson != null) {
for (final entry in allergensJson.entries) {
if (allergensRaw is Map) {
for (final entry in allergensRaw.entries) {
final value = entry.value;
if (value is int) {
allergens[entry.key] = value;
Expand Down Expand Up @@ -146,8 +146,8 @@ class Product {
notes: json['notes']?.toString(),
statusText: json['status_text']?.toString(),
bar: bar,
vegan: parsedVegan,
allergens: allergens,
isVegan: parsedVegan,
);
}

Expand All @@ -162,8 +162,8 @@ class Product {
if (notes != null) 'notes': notes,
if (statusText != null) 'status_text': statusText,
if (bar != null) 'bar': bar,
if (vegan != null) 'is_vegan': vegan,
'allergens': allergens,
if (isVegan != null) 'is_vegan': isVegan,
};
}

Expand Down Expand Up @@ -213,6 +213,10 @@ class Product {
if (allergenList.isEmpty) return null;
return allergenList.join(', ');
}

/// Returns true if the product has no declared allergens
bool get isAllergenFree =>
allergens.isEmpty || allergens.values.every((v) => v == 0);
}

/// Availability status for a product
Expand Down Expand Up @@ -252,10 +256,11 @@ class Drink {
String? get notes => product.notes;
String? get statusText => product.statusText;
String? get bar => product.bar;
bool? get vegan => product.vegan;
Map<String, int> get allergens => product.allergens;
AvailabilityStatus? get availabilityStatus => product.availabilityStatus;
String? get allergenText => product.allergenText;
bool? get isVegan => product.isVegan;
bool get isAllergenFree => product.isAllergenFree;

/// Generate a share message for this drink.
///
Expand Down
93 changes: 84 additions & 9 deletions lib/providers/beer_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ class BeerProvider extends ChangeNotifier {
DrinkSort _currentSort = DrinkSort.nameAsc;
String _searchQuery = '';
bool _showFavoritesOnly = false;
bool _hideUnavailable = false;
Set<DrinkVisibilityFilter> _visibilityFilters = {};
Set<String> _excludedAllergens = {};
ThemeMode _themeMode = ThemeMode.system;

// Timestamp tracking for automatic refresh
Expand Down Expand Up @@ -70,7 +71,16 @@ class BeerProvider extends ChangeNotifier {
DrinkSort get currentSort => _currentSort;
String get searchQuery => _searchQuery;
bool get showFavoritesOnly => _showFavoritesOnly;
bool get hideUnavailable => _hideUnavailable;
bool get hideUnavailable => _visibilityFilters.contains(DrinkVisibilityFilter.availableOnly);
Set<DrinkVisibilityFilter> get visibilityFilters => Set.unmodifiable(_visibilityFilters);
Set<String> get excludedAllergens => Set.unmodifiable(_excludedAllergens);
Set<String> get availableAllergens {
final allergens = <String>{};
for (final drink in _allDrinks) {
allergens.addAll(drink.allergens.keys);
}
return allergens;
}
bool get hasFestivals => _festivals.isNotEmpty;
ThemeMode get themeMode => _themeMode;
DateTime? get lastDrinksRefresh => _lastDrinksRefresh;
Expand Down Expand Up @@ -190,8 +200,25 @@ class BeerProvider extends ChangeNotifier {
final themeIndex = prefs.getInt('themeMode') ?? ThemeMode.system.index;
_themeMode = ThemeMode.values[themeIndex];

// Load hide unavailable preference
_hideUnavailable = prefs.getBool('hideUnavailable') ?? false;
// Load visibility filter preferences (with migration from legacy hideUnavailable key)
_visibilityFilters = {};
final savedFilters = prefs.getStringList('visibilityFilters');
if (savedFilters != null) {
for (final name in savedFilters) {
final filter = DrinkVisibilityFilter.values
.where((f) => f.name == name)
.firstOrNull;
if (filter != null) _visibilityFilters.add(filter);
}
} else {
// Migrate from legacy 'hideUnavailable' boolean preference
if (prefs.getBool('hideUnavailable') ?? false) {
_visibilityFilters.add(DrinkVisibilityFilter.availableOnly);
}
}
Comment on lines +203 to +218

// Load excluded allergens preference
_excludedAllergens = Set.from(prefs.getStringList('excludedAllergens') ?? []);

// Load festivals dynamically
await loadFestivals();
Expand Down Expand Up @@ -428,14 +455,61 @@ class BeerProvider extends ChangeNotifier {
}

/// Toggle hiding unavailable drinks and persist preference
Future<void> setHideUnavailable(bool value) async {
_hideUnavailable = value;
///
/// Convenience wrapper around [setVisibilityFilter] for backward compatibility.
Future<void> setHideUnavailable(bool value) =>
setVisibilityFilter(DrinkVisibilityFilter.availableOnly, value);

/// Set a visibility filter on or off and persist the preference
Future<void> setVisibilityFilter(DrinkVisibilityFilter filter, bool active) async {
if (active) {
_visibilityFilters = Set.from(_visibilityFilters)..add(filter);
} else {
_visibilityFilters = Set.from(_visibilityFilters)..remove(filter);
}
_applyFiltersAndSort();
notifyListeners();

// Persist the full set of active filters
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
'visibilityFilters',
_visibilityFilters.map((f) => f.name).toList(),
);
}

/// Clear all visibility filters and persist
Future<void> clearVisibilityFilters() async {
_visibilityFilters = {};
_applyFiltersAndSort();
notifyListeners();

final prefs = await SharedPreferences.getInstance();
await prefs.setStringList('visibilityFilters', []);
}

/// Toggle a per-allergen exclusion filter and persist
Future<void> setAllergenFilter(String allergen, bool active) async {
if (active) {
_excludedAllergens = Set.from(_excludedAllergens)..add(allergen);
} else {
_excludedAllergens = Set.from(_excludedAllergens)..remove(allergen);
}
_applyFiltersAndSort();
notifyListeners();

final prefs = await SharedPreferences.getInstance();
await prefs.setStringList('excludedAllergens', _excludedAllergens.toList());
}

/// Clear all allergen exclusion filters and persist
Future<void> clearAllergenFilters() async {
_excludedAllergens = {};
_applyFiltersAndSort();
notifyListeners();

// Persist the preference
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('hideUnavailable', value);
await prefs.setStringList('excludedAllergens', []);
}

/// Set theme mode and persist preference
Expand Down Expand Up @@ -531,7 +605,8 @@ class BeerProvider extends ChangeNotifier {
category: _selectedCategory,
styles: _selectedStyles,
favoritesOnly: _showFavoritesOnly,
hideUnavailable: _hideUnavailable,
visibilityFilters: _visibilityFilters,
excludedAllergens: _excludedAllergens,
searchQuery: _searchQuery,
);

Expand Down
2 changes: 1 addition & 1 deletion lib/screens/drink_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class _DrinkDetailScreenState extends State<DrinkDetailScreen> {
: theme.colorScheme.primary,
),
// Vegan indicator
if (drink.vegan == true)
if (drink.isVegan == true)
HeroInfoRow(
icon: Icons.eco,
text: 'Vegan',
Expand Down
Loading
Loading