import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.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'; import '../navigation/material3_page_route.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 _isMinimalView = false; bool _hasChanges = false; // Track if any changes were made @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) { _hasChanges = true; // Mark that changes were made ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Recipe deleted successfully'), backgroundColor: Colors.green, duration: Duration(seconds: 1), ), ); _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( Material3PageRoute( child: AddRecipeScreen(recipe: recipe, viewMode: true), useEmphasized: true, // Use emphasized easing for more fluid feel ), ); // Reload favourites after viewing (in case favorite was toggled) if (mounted) { _loadFavourites(); } } void _editRecipe(RecipeModel recipe) async { final result = await Navigator.of(context).push( Material3PageRoute( child: AddRecipeScreen(recipe: recipe), useEmphasized: true, // Use emphasized easing for more fluid feel ), ); 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) { _hasChanges = true; // Mark that changes were made _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 PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { // When navigating back, return true if changes were made if (!didPop) { Navigator.of(context).pop(_hasChanges); } }, child: Scaffold( appBar: AppBar( title: const Text('Favourites'), elevation: 0, actions: [ // View mode toggle icon IconButton( icon: Icon(_isMinimalView ? Icons.grid_view : Icons.view_list), onPressed: () { setState(() { _isMinimalView = !_isMinimalView; }); }, ), ], ), 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.symmetric(vertical: 8), itemCount: _recipes.length, itemBuilder: (context, index) { final recipe = _recipes[index]; return _RecipeCard( recipe: recipe, isMinimal: _isMinimalView, onTap: () => _viewRecipe(recipe), onFavouriteToggle: () => _toggleFavorite(recipe), ); }, ), ); } } /// Card widget for displaying a recipe in Favourites screen. class _RecipeCard extends StatelessWidget { final RecipeModel recipe; final bool isMinimal; final VoidCallback onTap; final VoidCallback onFavouriteToggle; const _RecipeCard({ required this.recipe, required this.isMinimal, required this.onTap, required this.onFavouriteToggle, }); void _openPhotoGallery(BuildContext context, int initialIndex) { if (recipe.imageUrls.isEmpty && recipe.videoUrls.isEmpty) return; Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: recipe.imageUrls, videoUrls: recipe.videoUrls, initialIndex: initialIndex, ), ), ); } Color _getRatingColor(int rating) { if (rating >= 4) return Colors.green; if (rating >= 2) return Colors.orange; return Colors.red; } @override Widget build(BuildContext context) { if (isMinimal) { return _buildMinimalCard(context); } else { return _buildFullCard(context); } } Widget _buildMinimalCard(BuildContext context) { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // Thumbnail (80x80) // Get first media (image or video) Builder( builder: (context) { final hasMedia = recipe.imageUrls.isNotEmpty || recipe.videoUrls.isNotEmpty; final isFirstVideo = recipe.imageUrls.isEmpty && recipe.videoUrls.isNotEmpty; final firstMediaUrl = recipe.imageUrls.isNotEmpty ? recipe.imageUrls.first : (recipe.videoUrls.isNotEmpty ? recipe.videoUrls.first : null); if (hasMedia && firstMediaUrl != null) { return GestureDetector( onTap: () => _openPhotoGallery(context, 0), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Container( width: 80, height: 80, decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(12), ), child: isFirstVideo ? _buildVideoThumbnail(firstMediaUrl) : _buildRecipeImage(firstMediaUrl), ), ), ); } else { return Container( width: 80, height: 80, decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(12), ), child: Icon( Icons.restaurant_menu, color: Colors.grey[400], size: 32, ), ); } }, ), const SizedBox(width: 16), // Content section Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ // Title Text( recipe.title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, fontSize: 16, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), // Icons and rating row Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ // Left side: Favorite icon InkWell( onTap: onFavouriteToggle, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(4), child: Icon( recipe.isFavourite ? Icons.favorite : Icons.favorite_border, color: recipe.isFavourite ? Colors.red : Colors.grey[600], size: 22, ), ), ), // Right side: Rating badge Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: _getRatingColor(recipe.rating).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.star, size: 18, color: Colors.amber, ), const SizedBox(width: 4), Text( recipe.rating.toString(), style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, color: _getRatingColor(recipe.rating), fontSize: 14, ), ), ], ), ), ], ), ], ), ), ], ), ), ), ); } Widget _buildFullCard(BuildContext context) { // Combine images and videos, show up to 3 media items final allMedia = <({String url, bool isVideo})>[]; for (final url in recipe.imageUrls.take(3)) { allMedia.add((url: url, isVideo: false)); } final remainingSlots = 3 - allMedia.length; if (remainingSlots > 0) { for (final url in recipe.videoUrls.take(remainingSlots)) { allMedia.add((url: url, isVideo: true)); } } final mediaToShow = allMedia.take(3).toList(); return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Media section with divided layout if (mediaToShow.isNotEmpty) ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), child: _buildPhotoLayout(context, mediaToShow), ), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Title row with actions Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( recipe.title, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, fontSize: 18, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), // Action icons and rating grouped together Row( mainAxisSize: MainAxisSize.min, children: [ // Favorite icon InkWell( onTap: onFavouriteToggle, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(6), child: Icon( recipe.isFavourite ? Icons.favorite : Icons.favorite_border, color: recipe.isFavourite ? Colors.red : Colors.grey[600], size: 20, ), ), ), const SizedBox(width: 8), // Rating badge Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5, ), decoration: BoxDecoration( color: _getRatingColor(recipe.rating).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(18), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.star, size: 16, color: Colors.amber, ), const SizedBox(width: 4), Text( recipe.rating.toString(), style: TextStyle( color: _getRatingColor(recipe.rating), fontWeight: FontWeight.bold, fontSize: 13, ), ), ], ), ), ], ), ], ), // Description if (recipe.description != null && recipe.description!.isNotEmpty) ...[ const SizedBox(height: 6), Text( recipe.description!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[600], fontSize: 14, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ], ), ), ], ), ), ); } Widget _buildPhotoLayout(BuildContext context, List<({String url, bool isVideo})> mediaToShow) { final mediaCount = mediaToShow.length; return AspectRatio( aspectRatio: 2 / 1, // More compact than 16/9 (which is ~1.78/1) child: Row( children: [ if (mediaCount == 1) // Single media: full width Expanded( child: _buildMediaTile(context, mediaToShow[0], 0, showBorder: false), ) else if (mediaCount == 2) // Two media: split 50/50 ...mediaToShow.asMap().entries.map((entry) { final index = entry.key; return Expanded( child: _buildMediaTile( context, entry.value, index, showBorder: index == 0, ), ); }) else // Three media: one large on left, two stacked on right Expanded( flex: 2, child: _buildMediaTile( context, mediaToShow[0], 0, showBorder: false, ), ), if (mediaCount == 3) ...[ const SizedBox(width: 2), Expanded( child: Column( children: [ Expanded( child: _buildMediaTile( context, mediaToShow[1], 1, showBorder: true, ), ), const SizedBox(height: 2), Expanded( child: _buildMediaTile( context, mediaToShow[2], 2, showBorder: false, ), ), ], ), ), ], ], ), ); } Widget _buildMediaTile(BuildContext context, ({String url, bool isVideo}) media, int index, {required bool showBorder}) { // Calculate the actual index in the gallery (accounting for images first, then videos) final actualIndex = media.isVideo ? recipe.imageUrls.length + recipe.videoUrls.indexOf(media.url) : recipe.imageUrls.indexOf(media.url); return GestureDetector( onTap: () => _openPhotoGallery(context, actualIndex), child: Container( decoration: showBorder ? BoxDecoration( border: Border( right: BorderSide( color: Colors.white.withValues(alpha: 0.3), width: 2, ), ), ) : null, child: media.isVideo ? _buildVideoThumbnail(media.url) : _buildRecipeImage(media.url), ), ); } Widget _buildRecipeImage(String imageUrl) { final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { return _FadeInImageWidget( imageUrl: imageUrl, isNetwork: true, ); } // Try to extract asset ID from Immich URL (format: .../api/assets/{id}/original) final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); String? assetId; if (assetIdMatch != null) { assetId = assetIdMatch.group(1); } else { // For Blossom URLs, use the full URL assetId = imageUrl; } if (assetId != null) { return FutureBuilder( future: mediaService.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 _FadeInImageWidget( imageBytes: snapshot.data!, ); }, ); } return _FadeInImageWidget( imageUrl: imageUrl, isNetwork: true, ); } Widget _buildVideoThumbnail(String videoUrl) { return _VideoThumbnailPreview(videoUrl: videoUrl); } } /// Widget that displays an image with a smooth fade-in animation. class _FadeInImageWidget extends StatefulWidget { final String? imageUrl; final Uint8List? imageBytes; final bool isNetwork; const _FadeInImageWidget({ this.imageUrl, this.imageBytes, this.isNetwork = false, }) : assert(imageUrl != null || imageBytes != null, 'Either imageUrl or imageBytes must be provided'); @override State<_FadeInImageWidget> createState() => _FadeInImageWidgetState(); } class _FadeInImageWidgetState extends State<_FadeInImageWidget> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _fadeAnimation; bool _imageLoaded = false; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); _fadeAnimation = CurvedAnimation( parent: _controller, curve: Curves.easeOut, ); // Start with opacity 0 _controller.value = 0.0; } @override void dispose() { _controller.dispose(); super.dispose(); } void _onImageLoaded() { if (!_imageLoaded && mounted) { _imageLoaded = true; _controller.forward(); } } @override Widget build(BuildContext context) { Widget imageWidget; if (widget.imageBytes != null) { // For memory images, use frameBuilder to detect when image is ready imageWidget = Image.memory( widget.imageBytes!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if ((wasSynchronouslyLoaded || frame != null) && !_imageLoaded) { WidgetsBinding.instance.addPostFrameCallback((_) => _onImageLoaded()); } return child; }, ); } else if (widget.imageUrl != null) { // For network images, use frameBuilder to detect when image is ready imageWidget = Image.network( widget.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) { // Image is loaded, return child and let frameBuilder handle fade-in return child; } return Container( color: Colors.grey.shade200, child: Center( child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ), ); }, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if ((wasSynchronouslyLoaded || frame != null) && !_imageLoaded) { WidgetsBinding.instance.addPostFrameCallback((_) => _onImageLoaded()); } return child; }, ); } else { return Container( color: Colors.grey.shade200, child: const Icon(Icons.broken_image, size: 48), ); } return FadeTransition( opacity: _fadeAnimation, child: imageWidget, ); } } /// Widget that displays a video thumbnail preview with a few seconds of playback. class _VideoThumbnailPreview extends StatefulWidget { final String videoUrl; const _VideoThumbnailPreview({ required this.videoUrl, }); @override State<_VideoThumbnailPreview> createState() => _VideoThumbnailPreviewState(); } class _VideoThumbnailPreviewState extends State<_VideoThumbnailPreview> { VideoPlayerController? _controller; bool _isInitialized = false; bool _hasError = false; @override void initState() { super.initState(); _extractThumbnail(); } Future _extractThumbnail() async { try { _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); await _controller!.initialize(); if (mounted && _controller != null) { // Seek to first frame and pause await _controller!.seekTo(Duration.zero); _controller!.pause(); setState(() { _isInitialized = true; }); } } catch (e) { Logger.warning('Failed to extract video thumbnail: $e'); if (mounted) { setState(() { _hasError = true; }); } } } @override void dispose() { _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (_hasError || !_isInitialized || _controller == null) { return Stack( fit: StackFit.expand, children: [ Container( color: Colors.black87, child: const Center( child: Icon( Icons.play_circle_filled, size: 40, color: Colors.white70, ), ), ), // Transparent play button overlay Center( child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), child: const Icon( Icons.play_arrow, size: 48, color: Colors.white, ), ), ), ], ); } return Stack( fit: StackFit.expand, children: [ // Video thumbnail (first frame) AspectRatio( aspectRatio: _controller!.value.aspectRatio, child: VideoPlayer(_controller!), ), // Transparent play button overlay centered Center( child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle, ), child: const Icon( Icons.play_arrow, size: 48, color: Colors.white, ), ), ), ], ); } }