Skip to content

Commit 706bc44

Browse files
Merge pull request #202 from richardthe3rd/copilot/sub-pr-200
Implement My Festival UI layer (Phase 4)
2 parents db8a05b + 0bcdf54 commit 706bc44

8 files changed

Lines changed: 528 additions & 139 deletions

File tree

lib/domain/repositories/api_drink_repository.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,10 @@ class ApiDrinkRepository implements DrinkRepository {
9898
final item = _favoritesService.getFavoriteItem(festivalId, drinkId);
9999
return Future.value(item?.tries.length ?? 0);
100100
}
101+
102+
@override
103+
Future<List<DateTime>> getTastingTimestamps(String festivalId, String drinkId) {
104+
final item = _favoritesService.getFavoriteItem(festivalId, drinkId);
105+
return Future.value(item?.tries ?? []);
106+
}
101107
}

lib/domain/repositories/drink_repository.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,7 @@ abstract class DrinkRepository {
4949

5050
/// Get the number of times a drink has been tasted
5151
Future<int> getTryCount(String festivalId, String drinkId);
52+
53+
/// Get all tasting timestamps for a drink
54+
Future<List<DateTime>> getTastingTimestamps(String festivalId, String drinkId);
5255
}

lib/main.dart

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -342,62 +342,4 @@ class _BeerFestivalHomeState extends State<BeerFestivalHome> {
342342
}
343343

344344
/// Screen showing favorited drinks
345-
class FavoritesScreen extends StatelessWidget {
346-
const FavoritesScreen({
347-
required this.festivalId,
348-
super.key,
349-
});
350345
351-
final String festivalId;
352-
353-
@override
354-
Widget build(BuildContext context) {
355-
final provider = context.watch<BeerProvider>();
356-
final favorites = provider.favoriteDrinks;
357-
final theme = Theme.of(context);
358-
359-
return Scaffold(
360-
appBar: AppBar(
361-
title: Column(
362-
crossAxisAlignment: CrossAxisAlignment.start,
363-
children: [
364-
Text(provider.currentFestival.name, style: theme.textTheme.titleMedium),
365-
Text('${favorites.length} favorites', style: theme.textTheme.bodySmall),
366-
],
367-
),
368-
actions: [
369-
buildOverflowMenu(context),
370-
],
371-
),
372-
body: favorites.isEmpty
373-
? Semantics(
374-
label: 'No favorites yet. Tap the heart icon on drinks you want to try.',
375-
child: Center(
376-
child: Column(
377-
mainAxisAlignment: MainAxisAlignment.center,
378-
children: [
379-
const Icon(Icons.favorite_border, size: 64, color: Colors.grey),
380-
const SizedBox(height: 16),
381-
Text('No favorites yet', style: theme.textTheme.titleLarge),
382-
const SizedBox(height: 8),
383-
const Text('Tap the ♡ on drinks you want to try'),
384-
],
385-
),
386-
),
387-
)
388-
: ListView.builder(
389-
padding: const EdgeInsets.only(bottom: 16),
390-
itemCount: favorites.length,
391-
itemBuilder: (context, index) {
392-
final drink = favorites[index];
393-
return DrinkCard(
394-
key: ValueKey(drink.id),
395-
drink: drink,
396-
onTap: () => context.go(buildDrinkDetailPath(festivalId, drink.id)),
397-
onFavoriteTap: () => provider.toggleFavorite(drink),
398-
);
399-
},
400-
),
401-
);
402-
}
403-
}

lib/providers/beer_provider.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,12 @@ class BeerProvider extends ChangeNotifier {
468468
return await _drinkRepository!.getTryCount(currentFestival.id, drink.id);
469469
}
470470

471+
/// Get all tasting timestamps for a drink
472+
Future<List<DateTime>> getTastingTimestamps(Drink drink) async {
473+
if (_drinkRepository == null) return [];
474+
return await _drinkRepository!.getTastingTimestamps(currentFestival.id, drink.id);
475+
}
476+
471477
/// Mark a drink as tasted (adds timestamp)
472478
Future<void> markAsTasted(Drink drink) async {
473479
if (_drinkRepository == null) return;

lib/screens/drink_detail_screen.dart

Lines changed: 152 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ class _DrinkDetailScreenState extends State<DrinkDetailScreen> {
111111
SliverToBoxAdapter(
112112
child: _buildBrewerySection(context, drink),
113113
),
114+
// Tasting history section
115+
SliverToBoxAdapter(
116+
child: _buildTastingHistory(context, drink, provider),
117+
),
114118
// Similar drinks
115119
..._buildSimilarDrinksSlivers(context, drink, provider),
116120
const SliverPadding(padding: EdgeInsets.only(bottom: 16)),
@@ -328,6 +332,98 @@ class _DrinkDetailScreenState extends State<DrinkDetailScreen> {
328332
);
329333
}
330334

335+
Widget _buildTastingHistory(BuildContext context, Drink drink, BeerProvider provider) {
336+
return FutureBuilder<List<DateTime>>(
337+
future: provider.getTastingTimestamps(drink),
338+
builder: (context, snapshot) {
339+
if (!snapshot.hasData || snapshot.data!.isEmpty) {
340+
return const SizedBox.shrink();
341+
}
342+
343+
final timestamps = snapshot.data!;
344+
345+
return Column(
346+
crossAxisAlignment: CrossAxisAlignment.start,
347+
children: [
348+
const SectionHeader(title: 'Tasting History'),
349+
Padding(
350+
padding: const EdgeInsets.symmetric(horizontal: 16.0),
351+
child: Card(
352+
child: Column(
353+
children: [
354+
for (int i = 0; i < timestamps.length; i++)
355+
Semantics(
356+
label: 'Tasting ${i + 1} on ${_formatTryDate(timestamps[i])}',
357+
button: true,
358+
child: ListTile(
359+
leading: const Icon(Icons.check_circle, color: Colors.green),
360+
title: Text(_formatTryDate(timestamps[i])),
361+
subtitle: i == 0 ? const Text('First tasting') : null,
362+
trailing: Semantics(
363+
label: 'Delete tasting from ${_formatTryDate(timestamps[i])}',
364+
hint: 'Double tap to delete this tasting timestamp',
365+
button: true,
366+
child: IconButton(
367+
icon: const Icon(Icons.delete_outline),
368+
tooltip: 'Delete tasting',
369+
onPressed: () => _confirmDeleteTry(context, drink, timestamps[i], provider),
370+
),
371+
),
372+
),
373+
),
374+
],
375+
),
376+
),
377+
),
378+
],
379+
);
380+
},
381+
);
382+
}
383+
384+
void _confirmDeleteTry(BuildContext context, Drink drink, DateTime tryDate, BeerProvider provider) {
385+
showDialog(
386+
context: context,
387+
builder: (context) => AlertDialog(
388+
title: const Text('Delete tasting?'),
389+
content: Text('Remove tasting from ${_formatTryDate(tryDate)}?'),
390+
actions: [
391+
TextButton(
392+
onPressed: () => Navigator.pop(context),
393+
child: const Text('Cancel'),
394+
),
395+
TextButton(
396+
onPressed: () async {
397+
Navigator.pop(context);
398+
await provider.deleteTry(drink, tryDate);
399+
if (context.mounted) {
400+
ScaffoldMessenger.of(context).showSnackBar(
401+
const SnackBar(
402+
content: Text('Tasting deleted'),
403+
duration: Duration(seconds: 1),
404+
),
405+
);
406+
}
407+
},
408+
child: const Text('Delete'),
409+
),
410+
],
411+
),
412+
);
413+
}
414+
415+
String _formatTryDate(DateTime date) {
416+
// Format like: "Dec 23, 2025 at 2:30 PM"
417+
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
418+
final month = months[date.month - 1];
419+
final day = date.day;
420+
final year = date.year;
421+
final hour = date.hour > 12 ? date.hour - 12 : (date.hour == 0 ? 12 : date.hour);
422+
final minute = date.minute.toString().padLeft(2, '0');
423+
final period = date.hour >= 12 ? 'PM' : 'AM';
424+
return '$month $day, $year at $hour:$minute $period';
425+
}
426+
331427
List<Widget> _buildSimilarDrinksSlivers(BuildContext context, Drink drink, BeerProvider provider) {
332428
final similarDrinksWithReasons = _getSimilarDrinksWithReasons(drink, provider.allDrinks);
333429

@@ -365,15 +461,62 @@ class _DrinkDetailScreenState extends State<DrinkDetailScreen> {
365461
Widget _buildBottomActionBar(BuildContext context, Drink drink, BeerProvider provider) {
366462
return BottomActionBar(
367463
actions: [
368-
// Tasted checkbox
369-
ActionButton(
370-
icon: drink.isTasted ? Icons.check_box : Icons.check_box_outline_blank,
371-
label: 'Tasted',
372-
isActive: drink.isTasted,
373-
onPressed: () => provider.toggleTasted(drink),
374-
semanticLabel: drink.isTasted
375-
? 'Mark ${drink.name} as not tasted'
376-
: 'Mark ${drink.name} as tasted',
464+
// Mark as Tasted button
465+
FutureBuilder<(String?, int)>(
466+
future: Future.wait([
467+
provider.getFavoriteStatus(drink),
468+
provider.getTryCount(drink),
469+
]).then((results) => (results[0] as String?, results[1] as int)),
470+
builder: (context, snapshot) {
471+
if (!snapshot.hasData) {
472+
return ActionButton(
473+
icon: Icons.check_circle_outline,
474+
label: 'Mark as Tasted',
475+
onPressed: () => provider.markAsTasted(drink),
476+
semanticLabel: 'Mark ${drink.name} as tasted',
477+
);
478+
}
479+
480+
final (status, tryCount) = snapshot.data!;
481+
482+
// Show different button based on status
483+
if (status == 'tasted' && tryCount > 0) {
484+
return ActionButton(
485+
icon: Icons.check_circle,
486+
label: 'Tasted ${tryCount}x',
487+
isActive: true,
488+
onPressed: () async {
489+
await provider.markAsTasted(drink);
490+
if (context.mounted) {
491+
ScaffoldMessenger.of(context).showSnackBar(
492+
const SnackBar(
493+
content: Text('Tasted again!'),
494+
duration: Duration(seconds: 1),
495+
),
496+
);
497+
}
498+
},
499+
semanticLabel: 'Mark ${drink.name} as tasted again. Currently tasted $tryCount times',
500+
);
501+
} else {
502+
return ActionButton(
503+
icon: Icons.check_circle_outline,
504+
label: 'Mark as Tasted',
505+
onPressed: () async {
506+
await provider.markAsTasted(drink);
507+
if (context.mounted) {
508+
ScaffoldMessenger.of(context).showSnackBar(
509+
const SnackBar(
510+
content: Text('Marked as tasted!'),
511+
duration: Duration(seconds: 1),
512+
),
513+
);
514+
}
515+
},
516+
semanticLabel: 'Mark ${drink.name} as tasted',
517+
);
518+
}
519+
},
377520
),
378521
// Rating
379522
Semantics(

0 commit comments

Comments
 (0)