Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ee613bb
feat: implement My Festival data model and service layer (Phase 3.1-3.2)
claude Jan 2, 2026
6a55f26
feat: complete My Festival data layer migration (Phase 3.3)
claude Jan 2, 2026
5c8624a
test: add BeerProvider tests for My Festival methods
claude Jan 2, 2026
ec480b3
Initial plan
Copilot Jan 2, 2026
d182620
feat: add type-safe FavoriteStatus enum and improve DateTime comparison
Copilot Jan 2, 2026
91ba27f
style: use const for Duration constructors in tests
Copilot Jan 2, 2026
db8a05b
Merge pull request #201 from richardthe3rd/copilot/sub-pr-200
richardthe3rd Jan 2, 2026
a476cf6
Initial plan
Copilot Jan 2, 2026
39d7fe4
feat: add status badges to DrinkCard (Task 4.1)
Copilot Jan 2, 2026
2b33128
feat: add tasting history UI to DrinkDetailScreen (Task 4.2)
Copilot Jan 2, 2026
0bcdf54
feat: create FavoritesScreen as Festival Log (Task 4.3)
Copilot Jan 2, 2026
706bc44
Merge pull request #202 from richardthe3rd/copilot/sub-pr-200
richardthe3rd Jan 2, 2026
9ff3b44
Initial plan
Copilot Jan 2, 2026
a74d841
Fix test failures: handle missing GoRouter in tests and fix bottom ac…
Copilot Jan 2, 2026
bfad100
Merge pull request #203 from richardthe3rd/copilot/sub-pr-200
richardthe3rd Jan 2, 2026
6ea9e49
Initial plan
Copilot Jan 3, 2026
8bbc70c
feat: improve Festival Log UI with bookmark icons and diary layout
Copilot Jan 3, 2026
42af7a3
Merge pull request #204 from richardthe3rd/copilot/sub-pr-200
richardthe3rd Jan 3, 2026
20580ca
Initial plan
Copilot Jan 3, 2026
9305945
fix: reposition tasted badge to bottom-right and hide want_to_try badge
Copilot Jan 3, 2026
f726d39
Merge pull request #205 from richardthe3rd/copilot/sub-pr-200
richardthe3rd Jan 3, 2026
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
32 changes: 30 additions & 2 deletions lib/domain/repositories/api_drink_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class ApiDrinkRepository implements DrinkRepository {
// Populate favorite status, ratings, and tasted status in a single pass
final favorites = _favoritesService.getFavorites(festival.id);
for (final drink in drinks) {
drink.isFavorite = favorites.contains(drink.id);
drink.isFavorite = favorites.containsKey(drink.id);
drink.rating = _ratingsService.getRating(festival.id, drink.id);
drink.isTasted = _tastingLogService.hasTasted(festival.id, drink.id);
}
Expand All @@ -38,7 +38,7 @@ class ApiDrinkRepository implements DrinkRepository {

@override
Future<List<String>> getFavorites(String festivalId) async {
return _favoritesService.getFavorites(festivalId).toList();
return _favoritesService.getFavorites(festivalId).keys.toList();
}

@override
Expand Down Expand Up @@ -76,4 +76,32 @@ class ApiDrinkRepository implements DrinkRepository {
Future<List<String>> getTastedDrinks(String festivalId) {
return Future.value(_tastingLogService.getTastedDrinkIds(festivalId));
}

@override
Future<String?> getFavoriteStatus(String festivalId, String drinkId) {
final item = _favoritesService.getFavoriteItem(festivalId, drinkId);
return Future.value(item?.status.value);
}

@override
Future<void> markAsTasted(String festivalId, String drinkId) {
return _favoritesService.markAsTasted(festivalId, drinkId);
}

@override
Future<void> deleteTry(String festivalId, String drinkId, DateTime timestamp) {
return _favoritesService.deleteTry(festivalId, drinkId, timestamp);
}

@override
Future<int> getTryCount(String festivalId, String drinkId) {
final item = _favoritesService.getFavoriteItem(festivalId, drinkId);
return Future.value(item?.tries.length ?? 0);
}

@override
Future<List<DateTime>> getTastingTimestamps(String festivalId, String drinkId) {
final item = _favoritesService.getFavoriteItem(festivalId, drinkId);
return Future.value(item?.tries ?? []);
}
}
15 changes: 15 additions & 0 deletions lib/domain/repositories/drink_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,19 @@ abstract class DrinkRepository {

/// Get list of tasted drink IDs for a festival
Future<List<String>> getTastedDrinks(String festivalId);

/// Get favorite status for a drink ('want_to_try', 'tasted', or null if not in log)
Future<String?> getFavoriteStatus(String festivalId, String drinkId);

/// Mark a drink as tasted (adds timestamp)
Future<void> markAsTasted(String festivalId, String drinkId);

/// Delete a specific tasting timestamp from a favorite item
Future<void> deleteTry(String festivalId, String drinkId, DateTime timestamp);

/// Get the number of times a drink has been tasted
Future<int> getTryCount(String festivalId, String drinkId);

/// Get all tasting timestamps for a drink
Future<List<DateTime>> getTastingTimestamps(String festivalId, String drinkId);
}
65 changes: 6 additions & 59 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,12 @@ class _ProviderInitializerState extends State<ProviderInitializer> with WidgetsB
if (!mounted) return;

try {
final router = GoRouter.of(context);
final router = GoRouter.maybeOf(context);
// If no router is available (e.g., in tests), skip redirects
if (router == null) {
return;
}

final state = GoRouterState.of(context);
final provider = context.read<BeerProvider>();

Expand Down Expand Up @@ -342,62 +347,4 @@ class _BeerFestivalHomeState extends State<BeerFestivalHome> {
}

/// Screen showing favorited drinks
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({
required this.festivalId,
super.key,
});

final String festivalId;

@override
Widget build(BuildContext context) {
final provider = context.watch<BeerProvider>();
final favorites = provider.favoriteDrinks;
final theme = Theme.of(context);

return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(provider.currentFestival.name, style: theme.textTheme.titleMedium),
Text('${favorites.length} favorites', style: theme.textTheme.bodySmall),
],
),
actions: [
buildOverflowMenu(context),
],
),
body: favorites.isEmpty
? Semantics(
label: 'No favorites yet. Tap the heart icon on drinks you want to try.',
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.favorite_border, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text('No favorites yet', style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
const Text('Tap the ♡ on drinks you want to try'),
],
),
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: favorites.length,
itemBuilder: (context, index) {
final drink = favorites[index];
return DrinkCard(
key: ValueKey(drink.id),
drink: drink,
onTap: () => context.go(buildDrinkDetailPath(festivalId, drink.id)),
onFavoriteTap: () => provider.toggleFavorite(drink),
);
},
),
);
}
}
147 changes: 147 additions & 0 deletions lib/models/favorite_item.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/// Status values for favorite items in the festival log.
enum FavoriteStatus {
/// Drink is on the 'want to try' list.
wantToTry('want_to_try'),

/// Drink has been tasted at least once.
tasted('tasted');

const FavoriteStatus(this.value);

/// The string value used for JSON serialization.
final String value;

/// Creates a FavoriteStatus from a string value.
static FavoriteStatus fromString(String value) {
return values.firstWhere(
(status) => status.value == value,
orElse: () => FavoriteStatus.wantToTry,
);
}
}

/// Represents a drink in the user's festival log.
///
/// Tracks whether a drink is on the 'want to try' list or has been tasted,
/// along with timestamps of tastings and optional notes.
class FavoriteItem {
/// Creates a favorite item.
const FavoriteItem({
required this.id,
required this.status,
required this.tries,
this.notes,
required this.createdAt,
required this.updatedAt,
});

/// Drink ID.
final String id;

/// Current status of this drink in the festival log.
final FavoriteStatus status;

/// List of tasting timestamps (empty if want_to_try).
final List<DateTime> tries;

/// Optional user notes.
final String? notes;

/// When this item was added to the log.
final DateTime createdAt;

/// When this item was last updated.
final DateTime updatedAt;

/// Creates a FavoriteItem from JSON.
factory FavoriteItem.fromJson(Map<String, dynamic> json) {
return FavoriteItem(
id: json['id'] as String,
status: FavoriteStatus.fromString(
json['status'] as String? ?? 'want_to_try',
),
tries: (json['tries'] as List?)
?.map((e) => DateTime.parse(e as String))
.toList() ??
[],
notes: json['notes'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}

/// Converts this item to JSON.
Map<String, dynamic> toJson() {
return {
'id': id,
'status': status.value,
'tries': tries.map((t) => t.toIso8601String()).toList(),
if (notes != null) 'notes': notes,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}

/// Creates a copy with updated fields.
///
/// To explicitly clear notes, pass an empty Optional: `notes: Optional.value(null)`.
/// To keep existing notes, omit the parameter: `copyWith(status: FavoriteStatus.tasted)`.
FavoriteItem copyWith({
String? id,
FavoriteStatus? status,
List<DateTime>? tries,
Optional<String?>? notes,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return FavoriteItem(
id: id ?? this.id,
status: status ?? this.status,
tries: tries ?? this.tries,
notes: notes != null ? notes.value : this.notes,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}

/// Equality comparison based on drink ID only.
///
/// Two FavoriteItems are considered equal if they have the same id,
/// regardless of status, tries, notes, or timestamps. This design
/// allows FavoriteItem to be used in Sets and as Map keys where
/// uniqueness is determined by the drink being tracked, not its
/// specific state.
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FavoriteItem &&
runtimeType == other.runtimeType &&
id == other.id;

@override
int get hashCode => id.hashCode;
Comment on lines +114 to +122

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The equality operator only compares the id field, ignoring all other fields (status, tries, notes, createdAt, updatedAt). This means two FavoriteItem instances with the same id but completely different data are considered equal. This could lead to unexpected behavior when using FavoriteItem in collections like Sets or as Map keys. Consider whether this is the intended behavior, or if equality should compare all fields. If id-only equality is intentional, add a comment explaining this design decision.

Copilot uses AI. Check for mistakes.
}

/// Wrapper class for explicitly passing null values in copyWith methods.
///
/// Used to distinguish between omitting a parameter (keep existing value)
/// and explicitly passing null (clear the value). This is particularly
/// useful for optional fields like notes where both "no change" and
/// "set to null" are valid operations.
///
/// Example usage:
/// ```dart
/// // Keep existing notes
/// item.copyWith(status: FavoriteStatus.tasted);
///
/// // Clear notes (set to null)
/// item.copyWith(notes: Optional.value(null));
///
/// // Set new notes value
/// item.copyWith(notes: Optional.value('Great beer!'));
/// ```
class Optional<T> {
const Optional.value(this.value);

final T value;
}
1 change: 1 addition & 0 deletions lib/models/models.dart
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export 'drink.dart';
export 'favorite_item.dart';
export 'festival.dart';
63 changes: 63 additions & 0 deletions lib/providers/beer_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,69 @@ class BeerProvider extends ChangeNotifier {
await prefs.setInt('themeMode', mode.index);
}

/// Get favorite status for a drink ('want_to_try', 'tasted', or null if not in log)
Future<String?> getFavoriteStatus(Drink drink) async {
if (_drinkRepository == null) return null;
return await _drinkRepository!.getFavoriteStatus(currentFestival.id, drink.id);
}

/// Check if drink is in festival log
bool isInFestivalLog(Drink drink) {
return drink.isFavorite;
}

/// Get try count for a drink
Future<int> getTryCount(Drink drink) async {
if (_drinkRepository == null) return 0;
return await _drinkRepository!.getTryCount(currentFestival.id, drink.id);
}

/// Get all tasting timestamps for a drink
Future<List<DateTime>> getTastingTimestamps(Drink drink) async {
if (_drinkRepository == null) return [];
return await _drinkRepository!.getTastingTimestamps(currentFestival.id, drink.id);
}

/// Mark a drink as tasted (adds timestamp)
Future<void> markAsTasted(Drink drink) async {
if (_drinkRepository == null) return;

await _drinkRepository!.markAsTasted(currentFestival.id, drink.id);

// Update drink state
drink.isFavorite = true;

notifyListeners();

// Log analytics event
final tryCount = await getTryCount(drink);
if (tryCount > 1) {
unawaited(_analyticsService.logFestivalLogMultipleTasting(drink.id, tryCount));
} else {
unawaited(_analyticsService.logFestivalLogMarkTasted(drink.id, tryCount));
}
}

/// Delete a specific tasting timestamp
Future<void> deleteTry(Drink drink, DateTime timestamp) async {
if (_drinkRepository == null) return;

await _drinkRepository!.deleteTry(currentFestival.id, drink.id, timestamp);

// Update drink state - check if still in favorites after deletion
final status = await _drinkRepository!.getFavoriteStatus(currentFestival.id, drink.id);
drink.isFavorite = status != null;

if (_showFavoritesOnly && status == null) {
_applyFiltersAndSort();
}

notifyListeners();

// Log analytics event
unawaited(_analyticsService.logFestivalLogDeleteTimestamp(drink.id));
}

/// Toggle favorite status for a drink
Future<void> toggleFavorite(Drink drink) async {
if (_drinkRepository == null) return;
Expand Down
Loading
Loading