diff --git a/lib/domain/repositories/api_drink_repository.dart b/lib/domain/repositories/api_drink_repository.dart index 431de307..d4197eef 100644 --- a/lib/domain/repositories/api_drink_repository.dart +++ b/lib/domain/repositories/api_drink_repository.dart @@ -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); } @@ -38,7 +38,7 @@ class ApiDrinkRepository implements DrinkRepository { @override Future> getFavorites(String festivalId) async { - return _favoritesService.getFavorites(festivalId).toList(); + return _favoritesService.getFavorites(festivalId).keys.toList(); } @override @@ -76,4 +76,32 @@ class ApiDrinkRepository implements DrinkRepository { Future> getTastedDrinks(String festivalId) { return Future.value(_tastingLogService.getTastedDrinkIds(festivalId)); } + + @override + Future getFavoriteStatus(String festivalId, String drinkId) { + final item = _favoritesService.getFavoriteItem(festivalId, drinkId); + return Future.value(item?.status.value); + } + + @override + Future markAsTasted(String festivalId, String drinkId) { + return _favoritesService.markAsTasted(festivalId, drinkId); + } + + @override + Future deleteTry(String festivalId, String drinkId, DateTime timestamp) { + return _favoritesService.deleteTry(festivalId, drinkId, timestamp); + } + + @override + Future getTryCount(String festivalId, String drinkId) { + final item = _favoritesService.getFavoriteItem(festivalId, drinkId); + return Future.value(item?.tries.length ?? 0); + } + + @override + Future> getTastingTimestamps(String festivalId, String drinkId) { + final item = _favoritesService.getFavoriteItem(festivalId, drinkId); + return Future.value(item?.tries ?? []); + } } diff --git a/lib/domain/repositories/drink_repository.dart b/lib/domain/repositories/drink_repository.dart index 619c8372..5154519d 100644 --- a/lib/domain/repositories/drink_repository.dart +++ b/lib/domain/repositories/drink_repository.dart @@ -37,4 +37,19 @@ abstract class DrinkRepository { /// Get list of tasted drink IDs for a festival Future> getTastedDrinks(String festivalId); + + /// Get favorite status for a drink ('want_to_try', 'tasted', or null if not in log) + Future getFavoriteStatus(String festivalId, String drinkId); + + /// Mark a drink as tasted (adds timestamp) + Future markAsTasted(String festivalId, String drinkId); + + /// Delete a specific tasting timestamp from a favorite item + Future deleteTry(String festivalId, String drinkId, DateTime timestamp); + + /// Get the number of times a drink has been tasted + Future getTryCount(String festivalId, String drinkId); + + /// Get all tasting timestamps for a drink + Future> getTastingTimestamps(String festivalId, String drinkId); } diff --git a/lib/main.dart b/lib/main.dart index 617c48f2..85119c93 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -156,7 +156,12 @@ class _ProviderInitializerState extends State 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(); @@ -342,62 +347,4 @@ class _BeerFestivalHomeState extends State { } /// 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(); - 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), - ); - }, - ), - ); - } -} diff --git a/lib/models/favorite_item.dart b/lib/models/favorite_item.dart new file mode 100644 index 00000000..58867d8f --- /dev/null +++ b/lib/models/favorite_item.dart @@ -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 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 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 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? tries, + Optional? 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; +} + +/// 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 { + const Optional.value(this.value); + + final T value; +} diff --git a/lib/models/models.dart b/lib/models/models.dart index 6db71300..e778ce70 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -1,2 +1,3 @@ export 'drink.dart'; +export 'favorite_item.dart'; export 'festival.dart'; diff --git a/lib/providers/beer_provider.dart b/lib/providers/beer_provider.dart index 51c52705..9ed971b2 100644 --- a/lib/providers/beer_provider.dart +++ b/lib/providers/beer_provider.dart @@ -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 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 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> getTastingTimestamps(Drink drink) async { + if (_drinkRepository == null) return []; + return await _drinkRepository!.getTastingTimestamps(currentFestival.id, drink.id); + } + + /// Mark a drink as tasted (adds timestamp) + Future 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 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 toggleFavorite(Drink drink) async { if (_drinkRepository == null) return; diff --git a/lib/screens/drink_detail_screen.dart b/lib/screens/drink_detail_screen.dart index dfca9856..289b56b9 100644 --- a/lib/screens/drink_detail_screen.dart +++ b/lib/screens/drink_detail_screen.dart @@ -111,6 +111,10 @@ class _DrinkDetailScreenState extends State { SliverToBoxAdapter( child: _buildBrewerySection(context, drink), ), + // Tasting history section + SliverToBoxAdapter( + child: _buildTastingHistory(context, drink, provider), + ), // Similar drinks ..._buildSimilarDrinksSlivers(context, drink, provider), const SliverPadding(padding: EdgeInsets.only(bottom: 16)), @@ -328,6 +332,98 @@ class _DrinkDetailScreenState extends State { ); } + Widget _buildTastingHistory(BuildContext context, Drink drink, BeerProvider provider) { + return FutureBuilder>( + future: provider.getTastingTimestamps(drink), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const SizedBox.shrink(); + } + + final timestamps = snapshot.data!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'Tasting History'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Card( + child: Column( + children: [ + for (int i = 0; i < timestamps.length; i++) + Semantics( + label: 'Tasting ${i + 1} on ${_formatTryDate(timestamps[i])}', + button: true, + child: ListTile( + leading: const Icon(Icons.check_circle, color: Colors.green), + title: Text(_formatTryDate(timestamps[i])), + subtitle: i == 0 ? const Text('First tasting') : null, + trailing: Semantics( + label: 'Delete tasting from ${_formatTryDate(timestamps[i])}', + hint: 'Double tap to delete this tasting timestamp', + button: true, + child: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete tasting', + onPressed: () => _confirmDeleteTry(context, drink, timestamps[i], provider), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + void _confirmDeleteTry(BuildContext context, Drink drink, DateTime tryDate, BeerProvider provider) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete tasting?'), + content: Text('Remove tasting from ${_formatTryDate(tryDate)}?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + await provider.deleteTry(drink, tryDate); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tasting deleted'), + duration: Duration(seconds: 1), + ), + ); + } + }, + child: const Text('Delete'), + ), + ], + ), + ); + } + + String _formatTryDate(DateTime date) { + // Format like: "Dec 23, 2025 at 2:30 PM" + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + final month = months[date.month - 1]; + final day = date.day; + final year = date.year; + final hour = date.hour > 12 ? date.hour - 12 : (date.hour == 0 ? 12 : date.hour); + final minute = date.minute.toString().padLeft(2, '0'); + final period = date.hour >= 12 ? 'PM' : 'AM'; + return '$month $day, $year at $hour:$minute $period'; + } + List _buildSimilarDrinksSlivers(BuildContext context, Drink drink, BeerProvider provider) { final similarDrinksWithReasons = _getSimilarDrinksWithReasons(drink, provider.allDrinks); @@ -365,15 +461,62 @@ class _DrinkDetailScreenState extends State { Widget _buildBottomActionBar(BuildContext context, Drink drink, BeerProvider provider) { return BottomActionBar( actions: [ - // Tasted checkbox - ActionButton( - icon: drink.isTasted ? Icons.check_box : Icons.check_box_outline_blank, - label: 'Tasted', - isActive: drink.isTasted, - onPressed: () => provider.toggleTasted(drink), - semanticLabel: drink.isTasted - ? 'Mark ${drink.name} as not tasted' - : 'Mark ${drink.name} as tasted', + // Mark as Tasted button + FutureBuilder<(String?, int)>( + future: Future.wait([ + provider.getFavoriteStatus(drink), + provider.getTryCount(drink), + ]).then((results) => (results[0] as String?, results[1] as int)), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return ActionButton( + icon: Icons.check_circle_outline, + label: 'Mark as Tasted', + onPressed: () => provider.markAsTasted(drink), + semanticLabel: 'Mark ${drink.name} as tasted', + ); + } + + final (status, tryCount) = snapshot.data!; + + // Show different button based on status + if (status == 'tasted' && tryCount > 0) { + return ActionButton( + icon: Icons.check_circle, + label: 'Tasted ${tryCount}x', + isActive: true, + onPressed: () async { + await provider.markAsTasted(drink); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tasted again!'), + duration: Duration(seconds: 1), + ), + ); + } + }, + semanticLabel: 'Mark ${drink.name} as tasted again. Currently tasted $tryCount times', + ); + } else { + return ActionButton( + icon: Icons.check_circle_outline, + label: 'Mark as Tasted', + onPressed: () async { + await provider.markAsTasted(drink); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Marked as tasted!'), + duration: Duration(seconds: 1), + ), + ); + } + }, + semanticLabel: 'Mark ${drink.name} as tasted', + ); + } + }, ), // Rating Semantics( @@ -410,15 +553,15 @@ class _DrinkDetailScreenState extends State { ), ), ), - // Favorite + // Want to Try / Bookmark ActionButton( - icon: drink.isFavorite ? Icons.favorite : Icons.favorite_border, - label: 'Favorite', + icon: drink.isFavorite ? Icons.bookmark : Icons.bookmark_border, + label: 'Want to Try', isActive: drink.isFavorite, onPressed: () => provider.toggleFavorite(drink), semanticLabel: drink.isFavorite - ? 'Remove ${drink.name} from favorites' - : 'Add ${drink.name} to favorites', + ? 'Remove ${drink.name} from want to try' + : 'Add ${drink.name} to want to try', ), // Share ActionButton( diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart new file mode 100644 index 00000000..2071b9f4 --- /dev/null +++ b/lib/screens/favorites_screen.dart @@ -0,0 +1,419 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import '../models/models.dart'; +import '../providers/providers.dart'; +import '../widgets/widgets.dart'; +import '../utils/navigation_helpers.dart'; + +/// Screen showing the user's festival log (My Festival) +class FavoritesScreen extends StatelessWidget { + const FavoritesScreen({ + required this.festivalId, + super.key, + }); + + final String festivalId; + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Scaffold( + body: RefreshIndicator( + onRefresh: () => provider.loadDrinks(), + child: CustomScrollView( + slivers: [ + SliverAppBar( + floating: true, + snap: true, + title: _buildTitle(context, provider), + actions: [ + buildOverflowMenu(context), + ], + ), + _buildFestivalLogSliver(context, provider), + ], + ), + ), + ); + } + + Widget _buildTitle(BuildContext context, BeerProvider provider) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('My Festival'), + Text( + provider.currentFestival.name, + style: theme.textTheme.labelSmall, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + Widget _buildFestivalLogSliver(BuildContext context, BeerProvider provider) { + // Get all favorite drinks + final allDrinks = provider.allDrinks.where((d) => d.isFavorite).toList(); + + if (allDrinks.isEmpty) { + return SliverFillRemaining( + child: _buildEmptyState(context), + ); + } + + // Build list of drinks with their statuses + return FutureBuilder)>>( + future: Future.wait( + allDrinks.map((drink) async { + final status = await provider.getFavoriteStatus(drink); + final tryCount = await provider.getTryCount(drink); + final timestamps = await provider.getTastingTimestamps(drink); + return (drink, status, tryCount, timestamps); + }), + ), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ); + } + + final drinksWithStatus = snapshot.data!; + + // Separate want to try and tasted drinks + final wantToTry = drinksWithStatus + .where((d) => d.$2 == 'want_to_try') + .toList(); + final tasted = drinksWithStatus + .where((d) => d.$2 == 'tasted') + .toList(); + + // Sort want to try by name + wantToTry.sort((a, b) => a.$1.name.compareTo(b.$1.name)); + + // Group tasted drinks by day of most recent tasting + final tastedByDay = )>>{}; + for (final item in tasted) { + final timestamps = item.$4; + if (timestamps.isNotEmpty) { + final mostRecent = timestamps.last; + final dayKey = _getDayLabel(mostRecent); + tastedByDay.putIfAbsent(dayKey, () => []).add(item); + } + } + + // Sort each day's drinks by most recent tasting time + for (final drinks in tastedByDay.values) { + drinks.sort((a, b) { + final timeA = a.$4.isNotEmpty ? a.$4.last : DateTime(2000); + final timeB = b.$4.isNotEmpty ? b.$4.last : DateTime(2000); + return timeB.compareTo(timeA); // Most recent first + }); + } + + // Get ordered list of day keys (most recent first) + final dayKeys = tastedByDay.keys.toList() + ..sort((a, b) { + final drinksA = tastedByDay[a]!; + final drinksB = tastedByDay[b]!; + final timeA = drinksA.first.$4.isNotEmpty ? drinksA.first.$4.last : DateTime(2000); + final timeB = drinksB.first.$4.isNotEmpty ? drinksB.first.$4.last : DateTime(2000); + return timeB.compareTo(timeA); // Most recent day first + }); + + // Build flat list of widgets + final widgets = []; + + // Add "Want to Try" section + if (wantToTry.isNotEmpty) { + widgets.add(_buildSectionHeader(context, 'Want to Try', wantToTry.length)); + for (final (drink, _, _, _) in wantToTry) { + widgets.add(_buildDrinkCard(context, drink)); + } + } + + // Add tasted sections by day + for (final dayKey in dayKeys) { + final dayDrinks = tastedByDay[dayKey]!; + widgets.add(_buildDayHeader(context, dayKey, dayDrinks.length)); + for (final (drink, _, tryCount, timestamps) in dayDrinks) { + widgets.add(_buildTastedDrinkCard( + context, + drink, + tryCount, + timestamps.isNotEmpty ? timestamps.last : DateTime.now(), + )); + } + } + + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widgets[index], + childCount: widgets.length, + ), + ); + }, + ); + } + + String _getDayLabel(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final dateDay = DateTime(date.year, date.month, date.day); + + final difference = today.difference(dateDay).inDays; + + if (difference == 0) { + return 'Today'; + } else if (difference == 1) { + return 'Yesterday'; + } else if (difference < 7) { + // Show day of week for recent dates + const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + return days[date.weekday - 1]; + } else { + // Show date for older entries + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return '${months[date.month - 1]} ${date.day}'; + } + } + + Widget _buildSectionHeader(BuildContext context, String title, int count) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Icon( + title == 'Want to Try' ? Icons.bookmark_border : Icons.check_circle_outline, + color: theme.colorScheme.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + count.toString(), + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ); + } + + Widget _buildDayHeader(BuildContext context, String dayLabel, int count) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Icon( + Icons.calendar_today, + color: theme.colorScheme.secondary, + size: 18, + ), + const SizedBox(width: 8), + Text( + dayLabel, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.secondary, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count.toString(), + style: theme.textTheme.labelSmall?.copyWith( + fontSize: 11, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ); + } + + Widget _buildDrinkCard(BuildContext context, Drink drink) { + final provider = context.read(); + return Semantics( + label: '${drink.name} by ${drink.breweryName}', + hint: 'Double tap to view drink details', + button: true, + child: DrinkCard( + drink: drink, + onTap: () => context.go(buildDrinkDetailPath(festivalId, drink.id)), + onFavoriteTap: () => provider.toggleFavorite(drink), + ), + ); + } + + Widget _buildTastedDrinkCard(BuildContext context, Drink drink, int tryCount, DateTime lastTasted) { + final provider = context.read(); + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + child: Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: Semantics( + label: '${drink.name} by ${drink.breweryName}, tasted at ${_formatTime(lastTasted)}', + hint: 'Double tap to view drink details', + button: true, + child: InkWell( + onTap: () => context.go(buildDrinkDetailPath(festivalId, drink.id)), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Check icon with count badge + Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.check_circle, color: Colors.green, size: 28), + if (tryCount > 1) + Positioned( + right: -6, + top: -6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + child: Text( + tryCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + // Drink info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drink.name, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + drink.breweryName, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(lastTasted), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + // Bookmark button + Semantics( + label: 'Remove from want to try', + hint: 'Double tap to toggle', + button: true, + child: IconButton( + icon: Icon( + drink.isFavorite ? Icons.bookmark : Icons.bookmark_border, + color: drink.isFavorite + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + onPressed: () => provider.toggleFavorite(drink), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + String _formatTime(DateTime time) { + final hour = time.hour > 12 ? time.hour - 12 : (time.hour == 0 ? 12 : time.hour); + final minute = time.minute.toString().padLeft(2, '0'); + final period = time.hour >= 12 ? 'PM' : 'AM'; + return '$hour:$minute $period'; + } + + Widget _buildEmptyState(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.bookmark_border, + size: 64, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'Your festival log is empty', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Tap the bookmark icon on drinks you want to try or mark drinks as tasted to build your festival log', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/screens.dart b/lib/screens/screens.dart index a96aeada..b5f97c14 100644 --- a/lib/screens/screens.dart +++ b/lib/screens/screens.dart @@ -2,5 +2,6 @@ export 'drinks_screen.dart'; export 'drink_detail_screen.dart'; export 'brewery_screen.dart'; export 'style_screen.dart'; +export 'favorites_screen.dart'; export 'festival_info_screen.dart'; export 'about_screen.dart'; diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart index f9240c17..d43a551a 100644 --- a/lib/services/analytics_service.dart +++ b/lib/services/analytics_service.dart @@ -191,6 +191,38 @@ class AnalyticsService { )); } + /// Log drink marked as tasted in festival log + Future logFestivalLogMarkTasted(String drinkId, int tryCount) async { + await _logIfEnabled(() => analytics.logEvent( + name: 'festival_log_mark_tasted', + parameters: { + 'drink_id': drinkId, + 'try_count': tryCount, + }, + )); + } + + /// Log multiple tasting of same drink + Future logFestivalLogMultipleTasting(String drinkId, int tryCount) async { + await _logIfEnabled(() => analytics.logEvent( + name: 'festival_log_multiple_tasting', + parameters: { + 'drink_id': drinkId, + 'try_count': tryCount, + }, + )); + } + + /// Log tasting timestamp deleted + Future logFestivalLogDeleteTimestamp(String drinkId) async { + await _logIfEnabled(() => analytics.logEvent( + name: 'festival_log_delete_timestamp', + parameters: { + 'drink_id': drinkId, + }, + )); + } + /// Log error to Crashlytics (non-fatal) Future logError(Object error, StackTrace? stackTrace, {String? reason}) async { try { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index d45bf50d..ba89fd12 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,57 +1,179 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../models/models.dart'; -/// Service for managing favorites locally +/// Service for managing favorites locally with My Festival tracking class FavoritesService { static const _favoritesKey = 'favorites'; - + final SharedPreferences _prefs; FavoritesService(this._prefs); - /// Get all favorite drink IDs for a festival - Set getFavorites(String festivalId) { + /// Get all favorite items for a festival + Map getFavorites(String festivalId) { + final key = '${_favoritesKey}_$festivalId'; + final data = _prefs.getString(key); + + if (data == null || data.isEmpty) { + return {}; // Empty map for new users + } + + try { + final json = jsonDecode(data) as Map; + return json.map( + (key, value) => MapEntry( + key, + FavoriteItem.fromJson(value as Map), + ), + ); + } catch (e) { + debugPrint('Error loading favorites: $e'); + return {}; // Return empty on error (corrupted data) + } + } + + /// Save all favorites for a festival + Future saveFavorites( + String festivalId, + Map favorites, + ) async { final key = '${_favoritesKey}_$festivalId'; - final favorites = _prefs.getStringList(key) ?? []; - return favorites.toSet(); + final json = favorites.map((key, value) => MapEntry(key, value.toJson())); + await _prefs.setString(key, jsonEncode(json)); } - /// Add a drink to favorites + /// Add a drink to favorites (want to try status) Future addFavorite(String festivalId, String drinkId) async { final favorites = getFavorites(festivalId); - favorites.add(drinkId); - await _saveFavorites(festivalId, favorites); + final now = DateTime.now(); + + favorites[drinkId] = FavoriteItem( + id: drinkId, + status: FavoriteStatus.wantToTry, + tries: [], + createdAt: now, + updatedAt: now, + ); + + await saveFavorites(festivalId, favorites); } /// Remove a drink from favorites Future removeFavorite(String festivalId, String drinkId) async { final favorites = getFavorites(festivalId); favorites.remove(drinkId); - await _saveFavorites(festivalId, favorites); + await saveFavorites(festivalId, favorites); } - /// Toggle favorite status + /// Toggle favorite status (add to want to try or remove from log) Future toggleFavorite(String festivalId, String drinkId) async { final favorites = getFavorites(festivalId); - final isFavorite = favorites.contains(drinkId); - + final isFavorite = favorites.containsKey(drinkId); + if (isFavorite) { favorites.remove(drinkId); } else { - favorites.add(drinkId); + final now = DateTime.now(); + favorites[drinkId] = FavoriteItem( + id: drinkId, + status: FavoriteStatus.wantToTry, + tries: [], + createdAt: now, + updatedAt: now, + ); } - - await _saveFavorites(festivalId, favorites); + + await saveFavorites(festivalId, favorites); return !isFavorite; } - /// Check if a drink is a favorite + /// Check if a drink is a favorite (in festival log) bool isFavorite(String festivalId, String drinkId) { - return getFavorites(festivalId).contains(drinkId); + return getFavorites(festivalId).containsKey(drinkId); } - Future _saveFavorites(String festivalId, Set favorites) async { - final key = '${_favoritesKey}_$festivalId'; - await _prefs.setStringList(key, favorites.toList()); + /// Get favorite item for a drink + FavoriteItem? getFavoriteItem(String festivalId, String drinkId) { + return getFavorites(festivalId)[drinkId]; + } + + /// Mark a drink as tasted (adds timestamp) + Future markAsTasted(String festivalId, String drinkId) async { + final favorites = getFavorites(festivalId); + final existing = favorites[drinkId]; + final now = DateTime.now(); + + if (existing == null) { + // Not in log yet, add as tasted + favorites[drinkId] = FavoriteItem( + id: drinkId, + status: FavoriteStatus.tasted, + tries: [now], + createdAt: now, + updatedAt: now, + ); + } else { + // Already in log, add timestamp and update status + favorites[drinkId] = existing.copyWith( + status: FavoriteStatus.tasted, + tries: [...existing.tries, now], + updatedAt: now, + ); + } + + await saveFavorites(festivalId, favorites); + } + + /// Delete a specific tasting timestamp + Future deleteTry( + String festivalId, + String drinkId, + DateTime timestamp, + ) async { + final favorites = getFavorites(festivalId); + final existing = favorites[drinkId]; + if (existing == null) return; + + final updatedTries = existing.tries + .where((t) => t.millisecondsSinceEpoch != timestamp.millisecondsSinceEpoch) + .toList(); + + if (updatedTries.isEmpty) { + // No more tries, revert to 'want to try' + favorites[drinkId] = existing.copyWith( + status: FavoriteStatus.wantToTry, + tries: [], + updatedAt: DateTime.now(), + ); + } else { + // Still has tries, just update list + favorites[drinkId] = existing.copyWith( + tries: updatedTries, + updatedAt: DateTime.now(), + ); + } + + await saveFavorites(festivalId, favorites); + } + + /// Update notes for a favorite item + Future updateNotes( + String festivalId, + String drinkId, + String? notes, + ) async { + final favorites = getFavorites(festivalId); + final existing = favorites[drinkId]; + if (existing == null) return; + + favorites[drinkId] = existing.copyWith( + notes: Optional.value(notes), + updatedAt: DateTime.now(), + ); + + await saveFavorites(festivalId, favorites); } } diff --git a/lib/widgets/bottom_action_bar.dart b/lib/widgets/bottom_action_bar.dart index 5ba1d320..2b4bdf14 100644 --- a/lib/widgets/bottom_action_bar.dart +++ b/lib/widgets/bottom_action_bar.dart @@ -38,7 +38,7 @@ class BottomActionBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: actions, + children: actions.map((action) => Expanded(child: action)).toList(), ), ), ), @@ -98,6 +98,9 @@ class ActionButton extends StatelessWidget { color: color, fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, ), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 1, ), ], ), diff --git a/lib/widgets/drink_card.dart b/lib/widgets/drink_card.dart index 3bd3f9b7..c86a13eb 100644 --- a/lib/widgets/drink_card.dart +++ b/lib/widgets/drink_card.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../models/models.dart'; +import '../providers/providers.dart'; import '../utils/utils.dart'; import 'info_chip.dart'; import 'star_rating.dart'; @@ -27,90 +29,96 @@ class DrinkCard extends StatelessWidget { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Semantics( - label: cardLabel, - hint: 'Double tap for details', - button: true, - excludeSemantics: true, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Stack( + children: [ + Semantics( + label: cardLabel, + hint: 'Double tap for details', + button: true, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText( - drink.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + drink.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + SelectableText( + drink.breweryLocation.isNotEmpty + ? '${drink.breweryName} • ${drink.breweryLocation}' + : drink.breweryName, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], ), - const SizedBox(height: 4), - SelectableText( - drink.breweryLocation.isNotEmpty - ? '${drink.breweryName} • ${drink.breweryLocation}' - : drink.breweryName, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + ), + Semantics( + label: drink.isFavorite ? 'Remove from want to try' : 'Add to want to try', + hint: 'Double tap to toggle', + button: true, + child: IconButton( + icon: Icon( + drink.isFavorite ? Icons.bookmark : Icons.bookmark_border, + color: drink.isFavorite + ? colorScheme.primary + : colorScheme.onSurfaceVariant, ), + onPressed: onFavoriteTap, ), - ], - ), - ), - Semantics( - label: drink.isFavorite ? 'Remove from favorites' : 'Add to favorites', - hint: 'Double tap to toggle', - button: true, - child: IconButton( - icon: Icon( - drink.isFavorite ? Icons.favorite : Icons.favorite_border, - color: drink.isFavorite - ? colorScheme.primary - : colorScheme.onSurfaceVariant, ), - onPressed: onFavoriteTap, - ), + ], ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - _CategoryChip(category: drink.category), - if (drink.style != null) - _StyleChip(style: drink.style!), - ExcludeSemantics( - child: InfoChip( - label: '${drink.abv.toStringAsFixed(1)}%', - icon: Icons.percent, - ), - ), - ExcludeSemantics( - child: InfoChip( - label: StringFormattingHelper.capitalizeFirst(drink.dispense), - icon: Icons.liquor, - ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + _CategoryChip(category: drink.category), + if (drink.style != null) + _StyleChip(style: drink.style!), + ExcludeSemantics( + child: InfoChip( + label: '${drink.abv.toStringAsFixed(1)}%', + icon: Icons.percent, + ), + ), + ExcludeSemantics( + child: InfoChip( + label: StringFormattingHelper.capitalizeFirst(drink.dispense), + icon: Icons.liquor, + ), + ), + if (drink.availabilityStatus != null) + _AvailabilityChip(status: drink.availabilityStatus!), + if (drink.rating != null) + _RatingChip(rating: drink.rating!), + ], ), - if (drink.availabilityStatus != null) - _AvailabilityChip(status: drink.availabilityStatus!), - if (drink.rating != null) - _RatingChip(rating: drink.rating!), ], ), - ], + ), ), ), - ), + // Status badge overlay + _StatusBadge(drink: drink), + ], ), ); } @@ -319,3 +327,83 @@ class _StyleChip extends StatelessWidget { ); } } + +/// Status badge showing festival log status +class _StatusBadge extends StatelessWidget { + final Drink drink; + + const _StatusBadge({required this.drink}); + + @override + Widget build(BuildContext context) { + // Try to get provider, but gracefully handle when it's not available (e.g., in tests) + final provider = context.watch(); + + if (provider == null) { + return const SizedBox.shrink(); // No provider, no badge + } + + return FutureBuilder<(String?, int)>( + future: Future.wait([ + provider.getFavoriteStatus(drink), + provider.getTryCount(drink), + ]).then((results) => (results[0] as String?, results[1] as int)), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + + final (status, tryCount) = snapshot.data!; + + // Only show badge for tasted drinks (bookmark button already indicates want_to_try) + if (status != 'tasted') { + return const SizedBox.shrink(); + } + + final (icon, color, label) = tryCount == 1 + ? (Icons.check_circle, Colors.green, 'Tasted once') + : (Icons.check_circle, Colors.green, 'Tasted $tryCount times'); + + return Positioned( + bottom: 8, + right: 8, + child: Semantics( + label: label, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 20), + if (tryCount > 1) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '${tryCount}x', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/test/beer_provider_test.dart b/test/beer_provider_test.dart index f76a18a3..d62c3c20 100644 --- a/test/beer_provider_test.dart +++ b/test/beer_provider_test.dart @@ -1150,6 +1150,307 @@ void main() { }); }); + group('My Festival methods', () { + test('getFavoriteStatus returns status from repository', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + when(mockDrinkRepository.getFavoriteStatus(any, any)) + .thenAnswer((_) async => 'want_to_try'); + + final status = await provider.getFavoriteStatus(sampleDrinks[0]); + expect(status, 'want_to_try'); + verify(mockDrinkRepository.getFavoriteStatus('cbf2025', 'drink-1')).called(1); + }); + + test('getFavoriteStatus returns null when no repository', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + + final drink = createSampleDrinks()[0]; + final status = await provider.getFavoriteStatus(drink); + expect(status, isNull); + }); + + test('getTryCount returns count from repository', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + when(mockDrinkRepository.getTryCount(any, any)) + .thenAnswer((_) async => 3); + + final count = await provider.getTryCount(sampleDrinks[0]); + expect(count, 3); + verify(mockDrinkRepository.getTryCount('cbf2025', 'drink-1')).called(1); + }); + + test('getTryCount returns 0 when no repository', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + + final drink = createSampleDrinks()[0]; + final count = await provider.getTryCount(drink); + expect(count, 0); + }); + + test('markAsTasted updates drink state and notifies listeners', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + when(mockDrinkRepository.markAsTasted(any, any)) + .thenAnswer((_) async {}); + when(mockDrinkRepository.getTryCount(any, any)) + .thenAnswer((_) async => 1); + + final drink = sampleDrinks[0]; + expect(drink.isFavorite, isFalse); + + var notified = false; + provider.addListener(() { + notified = true; + }); + + await provider.markAsTasted(drink); + + expect(drink.isFavorite, isTrue); + expect(notified, isTrue); + verify(mockDrinkRepository.markAsTasted('cbf2025', 'drink-1')).called(1); + }); + + test('markAsTasted logs first tasting event', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + when(mockDrinkRepository.markAsTasted(any, any)) + .thenAnswer((_) async {}); + when(mockDrinkRepository.getTryCount(any, any)) + .thenAnswer((_) async => 1); + + await provider.markAsTasted(sampleDrinks[0]); + + await Future.delayed(const Duration(milliseconds: 10)); + + verify(mockAnalyticsService.logFestivalLogMarkTasted('drink-1', 1)).called(1); + verifyNever(mockAnalyticsService.logFestivalLogMultipleTasting(any, any)); + }); + + test('markAsTasted logs multiple tasting event when count > 1', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + when(mockDrinkRepository.markAsTasted(any, any)) + .thenAnswer((_) async {}); + when(mockDrinkRepository.getTryCount(any, any)) + .thenAnswer((_) async => 3); + + await provider.markAsTasted(sampleDrinks[0]); + + await Future.delayed(const Duration(milliseconds: 10)); + + verify(mockAnalyticsService.logFestivalLogMultipleTasting('drink-1', 3)).called(1); + verifyNever(mockAnalyticsService.logFestivalLogMarkTasted(any, any)); + }); + + test('markAsTasted does nothing when no repository', () async { + provider = BeerProvider( + drinkRepository: null, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + + final drink = createSampleDrinks()[0]; + final initialFavorite = drink.isFavorite; + + await provider.markAsTasted(drink); + + expect(drink.isFavorite, initialFavorite); + }); + + test('deleteTry removes timestamp and updates drink state', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + final timestamp = DateTime.now(); + when(mockDrinkRepository.deleteTry(any, any, any)) + .thenAnswer((_) async {}); + when(mockDrinkRepository.getFavoriteStatus(any, any)) + .thenAnswer((_) async => 'tasted'); + + final drink = sampleDrinks[0]; + drink.isFavorite = true; + + var notified = false; + provider.addListener(() { + notified = true; + }); + + await provider.deleteTry(drink, timestamp); + + expect(drink.isFavorite, isTrue); + expect(notified, isTrue); + verify(mockDrinkRepository.deleteTry('cbf2025', 'drink-1', timestamp)).called(1); + }); + + test('deleteTry updates isFavorite to false when status becomes null', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + final timestamp = DateTime.now(); + when(mockDrinkRepository.deleteTry(any, any, any)) + .thenAnswer((_) async {}); + when(mockDrinkRepository.getFavoriteStatus(any, any)) + .thenAnswer((_) async => null); + + final drink = sampleDrinks[0]; + drink.isFavorite = true; + + await provider.deleteTry(drink, timestamp); + + expect(drink.isFavorite, isFalse); + }); + + test('deleteTry reapplies filters when showing favorites only', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + // Mark first drink as favorite + sampleDrinks[0].isFavorite = true; + provider.setShowFavoritesOnly(true); + + final timestamp = DateTime.now(); + when(mockDrinkRepository.deleteTry(any, any, any)) + .thenAnswer((_) async {}); + when(mockDrinkRepository.getFavoriteStatus(any, any)) + .thenAnswer((_) async => null); + + expect(provider.drinks.length, 1); + + await provider.deleteTry(sampleDrinks[0], timestamp); + + expect(provider.drinks.length, 0); + }); + + test('deleteTry logs analytics event', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + final timestamp = DateTime.now(); + when(mockDrinkRepository.deleteTry(any, any, any)) + .thenAnswer((_) async {}); + when(mockDrinkRepository.getFavoriteStatus(any, any)) + .thenAnswer((_) async => 'tasted'); + + await provider.deleteTry(sampleDrinks[0], timestamp); + + await Future.delayed(const Duration(milliseconds: 10)); + + verify(mockAnalyticsService.logFestivalLogDeleteTimestamp('drink-1')).called(1); + }); + + test('deleteTry does nothing when no repository', () async { + provider = BeerProvider( + drinkRepository: null, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + + final drink = createSampleDrinks()[0]; + drink.isFavorite = true; + final timestamp = DateTime.now(); + + await provider.deleteTry(drink, timestamp); + + expect(drink.isFavorite, isTrue); + }); + }); + group('automatic refresh', () { test('isDrinksDataStale returns true when no data loaded', () { provider = BeerProvider( diff --git a/test/brewery_screen_test.dart b/test/brewery_screen_test.dart index a27106d3..edf5bfa0 100644 --- a/test/brewery_screen_test.dart +++ b/test/brewery_screen_test.dart @@ -172,7 +172,7 @@ void main() { // Find and tap the favorite button final favoriteButton = find.descendant( of: find.byType(DrinkCard), - matching: find.byIcon(Icons.favorite_border), + matching: find.byIcon(Icons.bookmark_border), ); await tester.tap(favoriteButton); await tester.pumpAndSettle(); diff --git a/test/drink_card_test.dart b/test/drink_card_test.dart index f5fe2318..5f060308 100644 --- a/test/drink_card_test.dart +++ b/test/drink_card_test.dart @@ -113,8 +113,8 @@ void main() { testDrink.isFavorite = false; await tester.pumpWidget(createTestWidget(drink: testDrink)); - expect(find.byIcon(Icons.favorite_border), findsOneWidget); - expect(find.byIcon(Icons.favorite), findsNothing); + expect(find.byIcon(Icons.bookmark_border), findsOneWidget); + expect(find.byIcon(Icons.bookmark), findsNothing); }); testWidgets('shows favorite icon as filled when favorite', @@ -122,7 +122,7 @@ void main() { testDrink.isFavorite = true; await tester.pumpWidget(createTestWidget(drink: testDrink)); - expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.byIcon(Icons.bookmark), findsOneWidget); }); testWidgets('calls onTap when card is tapped', @@ -147,7 +147,7 @@ void main() { onFavoriteTap: () => favoriteTapped = true, )); - await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.tap(find.byIcon(Icons.bookmark_border)); await tester.pumpAndSettle(); expect(favoriteTapped, isTrue); diff --git a/test/drink_detail_screen_test.dart b/test/drink_detail_screen_test.dart index 36c32949..0d601e13 100644 --- a/test/drink_detail_screen_test.dart +++ b/test/drink_detail_screen_test.dart @@ -246,7 +246,7 @@ void main() { await tester.pumpWidget(createTestWidget('drink1')); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.byIcon(Icons.bookmark_border), findsOneWidget); }); testWidgets('toggles favorite when favorite button is tapped', @@ -259,7 +259,7 @@ void main() { await tester.pumpAndSettle(); expect(drink.isFavorite, false); - expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.byIcon(Icons.bookmark_border), findsOneWidget); // Mock toggleFavorite to properly toggle state final favorites = {}; @@ -275,11 +275,11 @@ void main() { }); // Tap favorite button - await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.tap(find.byIcon(Icons.bookmark_border)); await tester.pumpAndSettle(); expect(drink.isFavorite, true); - expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.byIcon(Icons.bookmark), findsOneWidget); }); testWidgets('navigates to brewery screen when brewery card is tapped', diff --git a/test/goldens/drink_detail_screen_long_name_light.png b/test/goldens/drink_detail_screen_long_name_light.png index ef8047a2..0b430a4e 100644 Binary files a/test/goldens/drink_detail_screen_long_name_light.png and b/test/goldens/drink_detail_screen_long_name_light.png differ diff --git a/test/goldens/drink_detail_screen_medium_name_light.png b/test/goldens/drink_detail_screen_medium_name_light.png index 804c490c..5c990907 100644 Binary files a/test/goldens/drink_detail_screen_medium_name_light.png and b/test/goldens/drink_detail_screen_medium_name_light.png differ diff --git a/test/models_test.dart b/test/models_test.dart index d0e3acac..73cfc076 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -1156,4 +1156,242 @@ void main() { expect(AvailabilityStatus.values, contains(AvailabilityStatus.notYetAvailable)); }); }); + + group('FavoriteItem', () { + final now = DateTime(2025, 5, 20, 14, 30); + final later = DateTime(2025, 5, 20, 18, 45); + + test('creates favorite item with all fields', () { + final item = FavoriteItem( + id: 'drink-123', + status: FavoriteStatus.wantToTry, + tries: [], + notes: 'Looks interesting', + createdAt: now, + updatedAt: now, + ); + + expect(item.id, 'drink-123'); + expect(item.status, FavoriteStatus.wantToTry); + expect(item.tries, isEmpty); + expect(item.notes, 'Looks interesting'); + expect(item.createdAt, now); + expect(item.updatedAt, now); + }); + + test('creates tasted item with tries', () { + final item = FavoriteItem( + id: 'drink-456', + status: FavoriteStatus.tasted, + tries: [now, later], + createdAt: now, + updatedAt: later, + ); + + expect(item.status, FavoriteStatus.tasted); + expect(item.tries.length, 2); + expect(item.tries, contains(now)); + expect(item.tries, contains(later)); + }); + + group('fromJson', () { + test('parses complete JSON correctly', () { + final json = { + 'id': 'drink-789', + 'status': 'tasted', + 'tries': [ + '2025-05-20T14:30:00.000Z', + '2025-05-20T18:45:00.000Z', + ], + 'notes': 'Excellent beer', + 'createdAt': '2025-05-20T10:00:00.000Z', + 'updatedAt': '2025-05-20T18:45:00.000Z', + }; + + final item = FavoriteItem.fromJson(json); + + expect(item.id, 'drink-789'); + expect(item.status, FavoriteStatus.tasted); + expect(item.tries.length, 2); + expect(item.notes, 'Excellent beer'); + }); + + test('handles missing optional fields', () { + final json = { + 'id': 'drink-minimal', + 'createdAt': '2025-05-20T10:00:00.000Z', + 'updatedAt': '2025-05-20T10:00:00.000Z', + }; + + final item = FavoriteItem.fromJson(json); + + expect(item.id, 'drink-minimal'); + expect(item.status, FavoriteStatus.wantToTry); // Default status + expect(item.tries, isEmpty); + expect(item.notes, isNull); + }); + + test('handles null tries list', () { + final json = { + 'id': 'drink-null-tries', + 'status': 'want_to_try', + 'tries': null, + 'createdAt': '2025-05-20T10:00:00.000Z', + 'updatedAt': '2025-05-20T10:00:00.000Z', + }; + + final item = FavoriteItem.fromJson(json); + + expect(item.tries, isEmpty); + }); + }); + + group('toJson', () { + test('converts to JSON correctly', () { + final item = FavoriteItem( + id: 'drink-abc', + status: FavoriteStatus.tasted, + tries: [now], + notes: 'Great!', + createdAt: now, + updatedAt: now, + ); + + final json = item.toJson(); + + expect(json['id'], 'drink-abc'); + expect(json['status'], 'tasted'); + expect(json['tries'], isList); + expect((json['tries'] as List).length, 1); + expect(json['notes'], 'Great!'); + expect(json['createdAt'], isA()); + expect(json['updatedAt'], isA()); + }); + + test('excludes null notes from JSON', () { + final item = FavoriteItem( + id: 'drink-no-notes', + status: FavoriteStatus.wantToTry, + tries: [], + createdAt: now, + updatedAt: now, + ); + + final json = item.toJson(); + + expect(json.containsKey('notes'), isFalse); + }); + + test('roundtrip through JSON maintains data', () { + final original = FavoriteItem( + id: 'drink-roundtrip', + status: FavoriteStatus.tasted, + tries: [now, later], + notes: 'Test notes', + createdAt: now, + updatedAt: later, + ); + + final json = original.toJson(); + final restored = FavoriteItem.fromJson(json); + + expect(restored.id, original.id); + expect(restored.status, original.status); + expect(restored.tries.length, original.tries.length); + expect(restored.notes, original.notes); + // Note: DateTime precision may vary through JSON serialization + expect(restored.createdAt.millisecondsSinceEpoch, + original.createdAt.millisecondsSinceEpoch); + expect(restored.updatedAt.millisecondsSinceEpoch, + original.updatedAt.millisecondsSinceEpoch); + }); + }); + + group('copyWith', () { + test('creates copy with updated fields', () { + final original = FavoriteItem( + id: 'drink-copy', + status: FavoriteStatus.wantToTry, + tries: [], + createdAt: now, + updatedAt: now, + ); + + final updated = original.copyWith( + status: FavoriteStatus.tasted, + tries: [later], + updatedAt: later, + ); + + expect(updated.id, original.id); // Unchanged + expect(updated.status, FavoriteStatus.tasted); // Changed + expect(updated.tries, [later]); // Changed + expect(updated.createdAt, original.createdAt); // Unchanged + expect(updated.updatedAt, later); // Changed + }); + + test('preserves unchanged fields', () { + final original = FavoriteItem( + id: 'drink-preserve', + status: FavoriteStatus.tasted, + tries: [now], + notes: 'Original notes', + createdAt: now, + updatedAt: now, + ); + + final copy = original.copyWith(); + + expect(copy.id, original.id); + expect(copy.status, original.status); + expect(copy.tries, original.tries); + expect(copy.notes, original.notes); + expect(copy.createdAt, original.createdAt); + expect(copy.updatedAt, original.updatedAt); + }); + }); + + group('equality', () { + test('equal items have same id', () { + final item1 = FavoriteItem( + id: 'drink-eq', + status: FavoriteStatus.wantToTry, + tries: [], + createdAt: now, + updatedAt: now, + ); + + final item2 = FavoriteItem( + id: 'drink-eq', + status: FavoriteStatus.tasted, + tries: [later], + createdAt: later, + updatedAt: later, + ); + + expect(item1, equals(item2)); // Same ID = equal + expect(item1.hashCode, equals(item2.hashCode)); + }); + + test('different items have different ids', () { + final item1 = FavoriteItem( + id: 'drink-1', + status: FavoriteStatus.wantToTry, + tries: [], + createdAt: now, + updatedAt: now, + ); + + final item2 = FavoriteItem( + id: 'drink-2', + status: FavoriteStatus.wantToTry, + tries: [], + createdAt: now, + updatedAt: now, + ); + + expect(item1, isNot(equals(item2))); + }); + }); + }); } diff --git a/test/provider_test.mocks.dart b/test/provider_test.mocks.dart index 95f58210..7e269fbc 100644 --- a/test/provider_test.mocks.dart +++ b/test/provider_test.mocks.dart @@ -205,6 +205,94 @@ class MockDrinkRepository extends _i1.Mock implements _i5.DrinkRepository { returnValue: _i6.Future>.value([]), returnValueForMissingStub: _i6.Future>.value([]), ) as _i6.Future>); + + @override + _i6.Future getFavoriteStatus( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #getFavoriteStatus, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future markAsTasted( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #markAsTasted, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future deleteTry( + String? festivalId, + String? drinkId, + DateTime? timestamp, + ) => + (super.noSuchMethod( + Invocation.method( + #deleteTry, + [ + festivalId, + drinkId, + timestamp, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future getTryCount( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #getTryCount, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future.value(0), + returnValueForMissingStub: _i6.Future.value(0), + ) as _i6.Future); + + @override + _i6.Future> getTastingTimestamps( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #getTastingTimestamps, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future>.value([]), + returnValueForMissingStub: + _i6.Future>.value([]), + ) as _i6.Future>); } /// A class which mocks [FestivalRepository]. @@ -446,6 +534,51 @@ class MockAnalyticsService extends _i1.Mock implements _i9.AnalyticsService { returnValueForMissingStub: _i6.Future.value(), ) as _i6.Future); + @override + _i6.Future logFestivalLogMarkTasted( + String? drinkId, + int? tryCount, + ) => + (super.noSuchMethod( + Invocation.method( + #logFestivalLogMarkTasted, + [ + drinkId, + tryCount, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logFestivalLogMultipleTasting( + String? drinkId, + int? tryCount, + ) => + (super.noSuchMethod( + Invocation.method( + #logFestivalLogMultipleTasting, + [ + drinkId, + tryCount, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logFestivalLogDeleteTimestamp(String? drinkId) => + (super.noSuchMethod( + Invocation.method( + #logFestivalLogDeleteTimestamp, + [drinkId], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + @override _i6.Future logError( Object? error, diff --git a/test/storage_service_test.dart b/test/storage_service_test.dart index e1bdc564..a1f8ece6 100644 --- a/test/storage_service_test.dart +++ b/test/storage_service_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:cambridge_beer_festival/models/models.dart'; import 'package:cambridge_beer_festival/services/storage_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -10,23 +11,26 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - test('getFavorites returns empty set for new festival', () async { + test('getFavorites returns empty map for new festival', () async { final prefs = await SharedPreferences.getInstance(); favoritesService = FavoritesService(prefs); final favorites = favoritesService.getFavorites('cbf2025'); expect(favorites, isEmpty); + expect(favorites, isA>()); }); - test('addFavorite adds drink to favorites', () async { + test('addFavorite adds drink to favorites with want_to_try status', () async { final prefs = await SharedPreferences.getInstance(); favoritesService = FavoritesService(prefs); await favoritesService.addFavorite('cbf2025', 'drink-123'); final favorites = favoritesService.getFavorites('cbf2025'); - expect(favorites, contains('drink-123')); + expect(favorites.containsKey('drink-123'), isTrue); + expect(favorites['drink-123']!.status, FavoriteStatus.wantToTry); + expect(favorites['drink-123']!.tries, isEmpty); }); test('addFavorite adds multiple drinks', () async { @@ -39,7 +43,7 @@ void main() { final favorites = favoritesService.getFavorites('cbf2025'); expect(favorites.length, 3); - expect(favorites, containsAll(['drink-1', 'drink-2', 'drink-3'])); + expect(favorites.keys, containsAll(['drink-1', 'drink-2', 'drink-3'])); }); test('removeFavorite removes drink from favorites', () async { @@ -72,6 +76,10 @@ void main() { expect(result, isTrue); expect(favoritesService.isFavorite('cbf2025', 'drink-123'), isTrue); + + final item = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + expect(item, isNotNull); + expect(item!.status, FavoriteStatus.wantToTry); }); test('toggleFavorite removes drink when already favorite', () async { @@ -114,7 +122,7 @@ void main() { expect(favoritesService.isFavorite('cbf2024', 'drink-123'), isFalse); }); - test('getFavorites returns separate sets for different festivals', () async { + test('getFavorites returns separate maps for different festivals', () async { final prefs = await SharedPreferences.getInstance(); favoritesService = FavoritesService(prefs); @@ -127,8 +135,179 @@ void main() { expect(favorites2025.length, 2); expect(favorites2024.length, 1); - expect(favorites2025, containsAll(['drink-a', 'drink-b'])); - expect(favorites2024, contains('drink-c')); + expect(favorites2025.keys, containsAll(['drink-a', 'drink-b'])); + expect(favorites2024.keys, contains('drink-c')); + }); + + test('markAsTasted creates tasted item with timestamp', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + + final item = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + expect(item, isNotNull); + expect(item!.status, FavoriteStatus.tasted); + expect(item.tries.length, 1); + }); + + test('markAsTasted adds timestamp to existing want_to_try item', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + await favoritesService.addFavorite('cbf2025', 'drink-123'); + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + + final item = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + expect(item, isNotNull); + expect(item!.status, FavoriteStatus.tasted); + expect(item.tries.length, 1); + }); + + test('markAsTasted can be called multiple times', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + + final item = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + expect(item, isNotNull); + expect(item!.tries.length, 3); + }); + + test('deleteTry removes specific timestamp', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + await Future.delayed(const Duration(milliseconds: 10)); // Ensure different timestamps + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + + final item = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + final firstTry = item!.tries.first; + + await favoritesService.deleteTry('cbf2025', 'drink-123', firstTry); + + final updated = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + expect(updated!.tries.length, 1); + expect(updated.tries.contains(firstTry), isFalse); + }); + + test('deleteTry reverts to want_to_try when last timestamp removed', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + + final item = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + final timestamp = item!.tries.first; + + await favoritesService.deleteTry('cbf2025', 'drink-123', timestamp); + + final updated = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + expect(updated!.status, FavoriteStatus.wantToTry); + expect(updated.tries, isEmpty); + }); + + test('deleteTry handles non-existent item gracefully', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + // Should not throw + await favoritesService.deleteTry( + 'cbf2025', + 'non-existent', + DateTime.now(), + ); + }); + + test('deleteTry works correctly after JSON serialization', () async { + // This test verifies that DateTime comparison works correctly + // after data is persisted and deserialized from JSON storage. + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + // Mark as tasted to create a timestamp + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + await Future.delayed(const Duration(milliseconds: 10)); // Ensure different timestamps + await favoritesService.markAsTasted('cbf2025', 'drink-123'); + + // Create new service instance to force reload from JSON + final newService = FavoritesService(prefs); + + // Get the timestamp from the reloaded data + final item = newService.getFavoriteItem('cbf2025', 'drink-123'); + expect(item, isNotNull); + expect(item!.tries.length, 2); + + final firstTimestamp = item.tries.first; + + // Delete the timestamp using the reloaded service + await newService.deleteTry('cbf2025', 'drink-123', firstTimestamp); + + // Verify the timestamp was deleted + final updated = newService.getFavoriteItem('cbf2025', 'drink-123'); + expect(updated!.tries.length, 1); + expect(updated.tries.contains(firstTimestamp), isFalse); + }); + + test('updateNotes sets notes on favorite item', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + await favoritesService.addFavorite('cbf2025', 'drink-123'); + await favoritesService.updateNotes('cbf2025', 'drink-123', 'Great beer!'); + + final item = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + expect(item!.notes, 'Great beer!'); + }); + + test('updateNotes can clear notes', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + await favoritesService.addFavorite('cbf2025', 'drink-123'); + await favoritesService.updateNotes('cbf2025', 'drink-123', 'Great beer!'); + await favoritesService.updateNotes('cbf2025', 'drink-123', null); + + final item = favoritesService.getFavoriteItem('cbf2025', 'drink-123'); + expect(item!.notes, isNull); + }); + + test('updateNotes handles non-existent item gracefully', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + // Should not throw + await favoritesService.updateNotes('cbf2025', 'non-existent', 'Test'); + }); + + test('saveFavorites persists data correctly', () async { + final prefs = await SharedPreferences.getInstance(); + favoritesService = FavoritesService(prefs); + + await favoritesService.addFavorite('cbf2025', 'drink-123'); + + // Create new service instance with same prefs + final service2 = FavoritesService(prefs); + final favorites = service2.getFavorites('cbf2025'); + + expect(favorites.containsKey('drink-123'), isTrue); + }); + + test('getFavorites handles corrupted data gracefully', () async { + final prefs = await SharedPreferences.getInstance(); + + // Manually set corrupted data + await prefs.setString('favorites_cbf2025', 'invalid json'); + + favoritesService = FavoritesService(prefs); + final favorites = favoritesService.getFavorites('cbf2025'); + + // Should return empty map instead of throwing + expect(favorites, isEmpty); }); }); diff --git a/test/style_screen_test.dart b/test/style_screen_test.dart index b574ec90..4882261e 100644 --- a/test/style_screen_test.dart +++ b/test/style_screen_test.dart @@ -188,7 +188,7 @@ void main() { // Find and tap the favorite button final favoriteButton = find.descendant( of: find.byType(DrinkCard), - matching: find.byIcon(Icons.favorite_border), + matching: find.byIcon(Icons.bookmark_border), ); await tester.tap(favoriteButton); await tester.pumpAndSettle();