import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../../core/service_locator.dart'; import '../../core/logger.dart'; import '../../data/recipes/recipe_service.dart'; import '../../data/recipes/models/recipe_model.dart'; import '../add_recipe/add_recipe_screen.dart'; import '../photo_gallery/photo_gallery_screen.dart'; /// Favourites screen displaying user's favorite recipes. class FavouritesScreen extends StatefulWidget { const FavouritesScreen({super.key}); @override State createState() => _FavouritesScreenState(); } class _FavouritesScreenState extends State { List _recipes = []; bool _isLoading = false; String? _errorMessage; RecipeService? _recipeService; bool _wasLoggedIn = false; bool _isCompactMode = false; @override void initState() { super.initState(); _initializeService(); _checkLoginState(); } @override void didChangeDependencies() { super.didChangeDependencies(); _checkLoginState(); if (_recipeService != null) { _loadFavourites(); } } @override void didUpdateWidget(FavouritesScreen oldWidget) { super.didUpdateWidget(oldWidget); _checkLoginState(); if (_recipeService != null) { _loadFavourites(); } } void _checkLoginState() { final sessionService = ServiceLocator.instance.sessionService; final isLoggedIn = sessionService?.isLoggedIn ?? false; if (isLoggedIn != _wasLoggedIn) { _wasLoggedIn = isLoggedIn; if (mounted) { setState(() {}); if (isLoggedIn && _recipeService != null) { _loadFavourites(); } } } } Future _initializeService() async { try { _recipeService = ServiceLocator.instance.recipeService; if (_recipeService == null) { throw Exception('RecipeService not available in ServiceLocator'); } _loadFavourites(); } catch (e) { Logger.error('Failed to initialize RecipeService', e); if (mounted) { setState(() { _errorMessage = 'Failed to initialize recipe service: $e'; _isLoading = false; }); } } } Future _loadFavourites() async { if (_recipeService == null) return; setState(() { _isLoading = true; _errorMessage = null; }); try { final recipes = await _recipeService!.getFavouriteRecipes(); Logger.info('FavouritesScreen: Loaded ${recipes.length} favourite recipe(s)'); if (mounted) { setState(() { _recipes = recipes; _isLoading = false; }); } } catch (e) { Logger.error('Failed to load favourites', e); if (mounted) { setState(() { _errorMessage = 'Failed to load favourites: $e'; _isLoading = false; }); } } } Future _deleteRecipe(RecipeModel recipe) async { if (_recipeService == null) return; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete Recipe'), content: Text('Are you sure you want to delete "${recipe.title}"?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; try { await _recipeService!.deleteRecipe(recipe.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Recipe deleted successfully'), backgroundColor: Colors.green, ), ); _loadFavourites(); } } catch (e) { Logger.error('Failed to delete recipe', e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to delete recipe: $e'), backgroundColor: Colors.red, ), ); } } } void _viewRecipe(RecipeModel recipe) async { await Navigator.of(context).push( MaterialPageRoute( builder: (context) => AddRecipeScreen(recipe: recipe, viewMode: true), ), ); // Reload favourites after viewing (in case favorite was toggled) if (mounted) { _loadFavourites(); } } void _editRecipe(RecipeModel recipe) async { final result = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => AddRecipeScreen(recipe: recipe), ), ); if (result == true || mounted) { _loadFavourites(); } } Future _toggleFavorite(RecipeModel recipe) async { if (_recipeService == null) return; try { final updatedRecipe = recipe.copyWith(isFavourite: !recipe.isFavourite); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { _loadFavourites(); } } catch (e) { Logger.error('Failed to toggle favorite', e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to update favorite: $e'), backgroundColor: Colors.red, ), ); } } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Favourites'), actions: [ // View mode toggle icons IconButton( icon: Icon( _isCompactMode ? Icons.view_list : Icons.view_list_outlined, color: _isCompactMode ? Theme.of(context).primaryColor : Colors.grey, ), onPressed: () { setState(() { _isCompactMode = true; }); }, tooltip: 'List View', ), IconButton( icon: Icon( !_isCompactMode ? Icons.grid_view : Icons.grid_view_outlined, color: !_isCompactMode ? Theme.of(context).primaryColor : Colors.grey, ), onPressed: () { setState(() { _isCompactMode = false; }); }, tooltip: 'Grid View', ), ], ), body: _buildBody(), ); } Widget _buildBody() { final sessionService = ServiceLocator.instance.sessionService; final isLoggedIn = sessionService?.isLoggedIn ?? false; if (!isLoggedIn) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.lock_outline, size: 64, color: Colors.grey.shade400), const SizedBox(height: 16), Text( 'Please log in to view favourites', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey.shade700, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( 'Favourites are associated with your user account', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), textAlign: TextAlign.center, ), ], ), ), ); } if (_isLoading) { return const Center( child: CircularProgressIndicator(), ); } if (_errorMessage != null) { return Center( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 64, color: Colors.red.shade300), const SizedBox(height: 16), Text( _errorMessage!, style: TextStyle(color: Colors.red.shade700), textAlign: TextAlign.center, ), const SizedBox(height: 16), ElevatedButton( onPressed: _loadFavourites, child: const Text('Retry'), ), ], ), ), ); } if (_recipes.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.favorite_outline, size: 64, color: Colors.grey.shade400), const SizedBox(height: 16), Text( 'No favourites yet', style: TextStyle( fontSize: 18, color: Colors.grey.shade600, ), ), const SizedBox(height: 8), Text( 'Tap the heart icon on recipes to add them to favourites', style: TextStyle( fontSize: 14, color: Colors.grey.shade500, ), textAlign: TextAlign.center, ), ], ), ); } return RefreshIndicator( onRefresh: _loadFavourites, child: ListView.builder( padding: const EdgeInsets.all(16), itemCount: _recipes.length, itemBuilder: (context, index) { final recipe = _recipes[index]; return _RecipeCard( recipe: recipe, isCompact: _isCompactMode, onTap: () => _viewRecipe(recipe), onFavorite: () => _toggleFavorite(recipe), onEdit: () => _editRecipe(recipe), onDelete: () => _deleteRecipe(recipe), ); }, ), ); } } /// Card widget for displaying a recipe (reused from RecipesScreen). /// This is a copy of the _RecipeCard from recipes_screen.dart to avoid making it public. class _RecipeCard extends StatelessWidget { final RecipeModel recipe; final bool isCompact; final VoidCallback onTap; final VoidCallback onFavorite; final VoidCallback onEdit; final VoidCallback onDelete; const _RecipeCard({ required this.recipe, required this.isCompact, required this.onTap, required this.onFavorite, required this.onEdit, required this.onDelete, }); void _openPhotoGallery(BuildContext context, int initialIndex) { if (recipe.imageUrls.isEmpty) return; Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: recipe.imageUrls, initialIndex: initialIndex, ), ), ); } /// Builds an image widget using ImmichService for authenticated access. Widget _buildRecipeImage(String imageUrl) { final immichService = ServiceLocator.instance.immichService; final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); if (assetIdMatch != null && immichService != null) { final assetId = assetIdMatch.group(1); if (assetId != null) { return FutureBuilder( future: immichService.fetchImageBytes(assetId, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return Container( color: Colors.grey.shade200, child: const Center( child: CircularProgressIndicator(), ), ); } if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { return Container( color: Colors.grey.shade200, child: const Icon(Icons.broken_image, size: 48), ); } return Image.memory( snapshot.data!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, ); }, ); } } return Image.network( imageUrl, fit: BoxFit.cover, width: double.infinity, height: double.infinity, errorBuilder: (context, error, stackTrace) { return Container( color: Colors.grey.shade200, child: const Icon(Icons.broken_image, size: 48), ); }, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Container( color: Colors.grey.shade200, child: Center( child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ), ); }, ); } /// Builds a mosaic-style image grid for multiple images. Widget _buildMosaicImages(BuildContext context, List imageUrls) { if (imageUrls.isEmpty) return const SizedBox.shrink(); if (imageUrls.length == 1) { return ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: AspectRatio( aspectRatio: 16 / 9, child: GestureDetector( onTap: () => _openPhotoGallery(context, 0), child: _buildRecipeImage(imageUrls.first), ), ), ); } return ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final height = width * 0.6; if (imageUrls.length == 2) { return SizedBox( height: height, child: Row( children: [ Expanded( child: GestureDetector( onTap: () => _openPhotoGallery(context, 0), child: ClipRect( child: _buildRecipeImage(imageUrls[0]), ), ), ), Expanded( child: GestureDetector( onTap: () => _openPhotoGallery(context, 1), child: ClipRect( child: _buildRecipeImage(imageUrls[1]), ), ), ), ], ), ); } else if (imageUrls.length == 3) { return SizedBox( height: height, child: Row( children: [ Expanded( flex: 2, child: GestureDetector( onTap: () => _openPhotoGallery(context, 0), child: ClipRect( child: _buildRecipeImage(imageUrls[0]), ), ), ), Expanded( child: Column( children: [ Expanded( child: GestureDetector( onTap: () => _openPhotoGallery(context, 1), child: ClipRect( child: _buildRecipeImage(imageUrls[1]), ), ), ), Expanded( child: GestureDetector( onTap: () => _openPhotoGallery(context, 2), child: ClipRect( child: _buildRecipeImage(imageUrls[2]), ), ), ), ], ), ), ], ), ); } else { return SizedBox( height: height, child: GridView.builder( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, padding: EdgeInsets.zero, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 0, mainAxisSpacing: 0, childAspectRatio: 1.0, ), itemCount: imageUrls.length > 4 ? 4 : imageUrls.length, itemBuilder: (context, index) { return GestureDetector( onTap: () => _openPhotoGallery(context, index), child: ClipRect( child: Stack( fit: StackFit.expand, children: [ _buildRecipeImage(imageUrls[index]), if (index == 3 && imageUrls.length > 4) Container( color: Colors.black.withOpacity(0.5), child: Center( child: Text( '+${imageUrls.length - 4}', style: const TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, ), ), ), ), ], ), ), ); }, ), ); } }, ), ); } @override Widget build(BuildContext context) { if (isCompact) { return _buildCompactCard(context); } else { return _buildExpandedCard(context); } } Widget _buildCompactCard(BuildContext context) { return Card( margin: const EdgeInsets.only(bottom: 8), elevation: 1, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(8), child: Row( children: [ if (recipe.imageUrls.isNotEmpty) GestureDetector( onTap: () => _openPhotoGallery(context, 0), child: ClipRRect( borderRadius: BorderRadius.circular(6), child: SizedBox( width: 80, height: 80, child: _buildRecipeImage(recipe.imageUrls.first), ), ), ), if (recipe.imageUrls.isNotEmpty) const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( recipe.title, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( icon: Icon( recipe.isFavourite ? Icons.favorite : Icons.favorite_border, color: recipe.isFavourite ? Colors.red : Colors.grey, size: 24, ), onPressed: onFavorite, padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: recipe.isFavourite ? 'Remove from favorites' : 'Add to favorites', ), const SizedBox(width: 8), const Icon( Icons.star, size: 20, color: Colors.amber, ), const SizedBox(width: 4), Text( recipe.rating.toString(), style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), ], ), ), PopupMenuButton( icon: const Icon(Icons.more_vert, size: 20), itemBuilder: (context) => [ PopupMenuItem( child: const Row( children: [ Icon(Icons.edit, size: 18), SizedBox(width: 8), Text('Edit'), ], ), onTap: () => Future.delayed( const Duration(milliseconds: 100), onEdit, ), ), PopupMenuItem( child: const Row( children: [ Icon(Icons.delete, size: 18, color: Colors.red), SizedBox(width: 8), Text('Delete', style: TextStyle(color: Colors.red)), ], ), onTap: () => Future.delayed( const Duration(milliseconds: 100), onDelete, ), ), ], ), ], ), ), ), ); } Widget _buildExpandedCard(BuildContext context) { return Card( margin: const EdgeInsets.only(bottom: 16), elevation: 2, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (recipe.imageUrls.isNotEmpty) _buildMosaicImages(context, recipe.imageUrls), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( recipe.title, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), ), if (recipe.rating > 0) Row( children: List.generate(5, (index) { return Icon( index < recipe.rating ? Icons.star : Icons.star_border, size: 16, color: index < recipe.rating ? Colors.amber : Colors.grey, ); }), ), ], ), if (recipe.description != null && recipe.description!.isNotEmpty) ...[ const SizedBox(height: 8), Text( recipe.description!, style: TextStyle( fontSize: 14, color: Colors.grey.shade700, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], if (recipe.tags.isNotEmpty) ...[ const SizedBox(height: 8), Wrap( spacing: 4, runSpacing: 4, children: recipe.tags.map((tag) { return Chip( label: Text( tag, style: const TextStyle(fontSize: 12), ), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ); }).toList(), ), ], const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( icon: Icon( recipe.isFavourite ? Icons.favorite : Icons.favorite_border, color: recipe.isFavourite ? Colors.red : Colors.grey, ), onPressed: onFavorite, tooltip: recipe.isFavourite ? 'Remove from favorites' : 'Add to favorites', ), IconButton( icon: const Icon(Icons.edit), onPressed: onEdit, tooltip: 'Edit', ), IconButton( icon: const Icon(Icons.delete), onPressed: onDelete, tooltip: 'Delete', color: Colors.red, ), ], ), ], ), ), ], ), ), ); } }