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 '../../data/local/models/item.dart'; import '../add_recipe/add_recipe_screen.dart'; import '../photo_gallery/photo_gallery_screen.dart'; import '../navigation/main_navigation_scaffold.dart'; import 'bookmark_dialog.dart'; import '../../data/recipes/models/bookmark_category_model.dart'; /// Recipes screen displaying user's recipe collection. class RecipesScreen extends StatefulWidget { const RecipesScreen({super.key}); @override State createState() => _RecipesScreenState(); /// Refreshes recipes from Nostr and reloads from database. /// This can be called externally to trigger a refresh. static void refresh(BuildContext? context) { if (context == null) return; final state = context.findAncestorStateOfType<_RecipesScreenState>(); state?.refreshFromNostr(); } } class _RecipesScreenState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin { List _recipes = []; List _filteredRecipes = []; bool _isLoading = false; String? _errorMessage; RecipeService? _recipeService; bool _wasLoggedIn = false; bool _isMinimalView = false; final TextEditingController _searchController = TextEditingController(); bool _isSearching = false; DateTime? _lastRefreshTime; Set _selectedTags = {}; // Currently selected tags for filtering // Auto-scroll animation for tag filter bar ScrollController? _tagScrollController; AnimationController? _tagAnimationController; Animation? _tagAnimation; bool _isUserScrollingTags = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _initializeService(); _checkLoginState(); _loadPreferences(); _searchController.addListener(_onSearchChanged); // Initialize tag scroll animation _tagScrollController = ScrollController(); _tagAnimationController = AnimationController( vsync: this, duration: const Duration(seconds: 8), ); _tagAnimation = Tween(begin: 0, end: 0.3).animate( CurvedAnimation(parent: _tagAnimationController!, curve: Curves.easeInOut), ); _startTagAutoScroll(); _tagAnimation?.addListener(_animateTagScroll); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _searchController.dispose(); _tagAnimationController?.dispose(); _tagScrollController?.dispose(); super.dispose(); } void _startTagAutoScroll() { if (!_isUserScrollingTags && _tagAnimationController != null) { _tagAnimationController!.repeat(reverse: true); } } void _animateTagScroll() { if (!_isUserScrollingTags && _tagScrollController != null && _tagScrollController!.hasClients && _tagAnimation != null) { final allTags = _getFilteredTags().toList(); if (allTags.length > 3) { final maxScroll = _tagScrollController!.position.maxScrollExtent; if (maxScroll > 0) { final targetScroll = _tagAnimation!.value * maxScroll; if ((_tagScrollController!.offset - targetScroll).abs() > 1) { _tagScrollController!.jumpTo(targetScroll); } } } } } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); // Refresh recipes when app resumes to ensure UI is up-to-date if (state == AppLifecycleState.resumed && _recipeService != null) { _refreshIfNeeded(); } } Future _loadPreferences() async { try { final localStorage = ServiceLocator.instance.localStorageService; final item = await localStorage.getItem('app_preferences'); if (item != null && item.data['isMinimalView'] != null) { setState(() { _isMinimalView = item.data['isMinimalView'] as bool; }); } } catch (e) { Logger.warning('Failed to load preferences: $e'); } } Future _savePreferences() async { try { final localStorage = ServiceLocator.instance.localStorageService; final existingItem = await localStorage.getItem('app_preferences'); final item = Item( id: 'app_preferences', data: { ...?existingItem?.data, 'isMinimalView': _isMinimalView, }, ); if (existingItem != null) { await localStorage.updateItem(item); } else { await localStorage.insertItem(item); } } catch (e) { Logger.warning('Failed to save preferences: $e'); } } void _onSearchChanged() { _filterRecipes(); } void _filterRecipes() { final query = _searchController.text.toLowerCase(); setState(() { if (query.isEmpty && _selectedTags.isEmpty) { _filteredRecipes = List.from(_recipes); } else { _filteredRecipes = _recipes.where((recipe) { // Tag filter - recipe must contain ALL selected tags if (_selectedTags.isNotEmpty) { if (!_selectedTags.every((tag) => recipe.tags.contains(tag))) { return false; } } // Search filter if (query.isNotEmpty) { final titleMatch = recipe.title.toLowerCase().contains(query); final descriptionMatch = recipe.description?.toLowerCase().contains(query) ?? false; final tagsMatch = recipe.tags.any((tag) => tag.toLowerCase().contains(query)); return titleMatch || descriptionMatch || tagsMatch; } return true; }).toList(); } }); } /// Gets all unique tags from all recipes Set _getAllTags() { final tags = {}; for (final recipe in _recipes) { tags.addAll(recipe.tags); } return tags; } /// Gets tags from recipes that match the current search query Set _getFilteredTags() { final query = _searchController.text.toLowerCase(); final tags = {}; // If there's a search query, only get tags from matching recipes if (query.isNotEmpty) { for (final recipe in _recipes) { final titleMatch = recipe.title.toLowerCase().contains(query); final descriptionMatch = recipe.description?.toLowerCase().contains(query) ?? false; final tagsMatch = recipe.tags.any((tag) => tag.toLowerCase().contains(query)); if (titleMatch || descriptionMatch || tagsMatch) { tags.addAll(recipe.tags); } } } else { // If no search query, return all tags return _getAllTags(); } return tags; } /// Handles tag selection for filtering void _selectTag(String tag) { // Stop auto-scroll permanently when user selects a tag _isUserScrollingTags = true; _tagAnimationController?.stop(); setState(() { if (_selectedTags.contains(tag)) { _selectedTags.remove(tag); } else { _selectedTags.add(tag); } // Automatically activate search form when tags are selected if (_selectedTags.isNotEmpty && !_isSearching) { _isSearching = true; } _filterRecipes(); }); } @override void didChangeDependencies() { super.didChangeDependencies(); _checkLoginState(); if (_recipeService != null) { // Refresh when dependencies change (e.g., returning from another screen) _refreshIfNeeded(); } } /// Refreshes recipes if enough time has passed since last refresh. /// This prevents excessive refreshes while ensuring UI stays up-to-date. void _refreshIfNeeded() { final now = DateTime.now(); // Refresh if never refreshed, or if more than 1 second has passed if (_lastRefreshTime == null || now.difference(_lastRefreshTime!).inSeconds > 1) { _lastRefreshTime = now; _loadRecipes(fetchFromNostr: false, showLoading: false); } } @override void didUpdateWidget(RecipesScreen oldWidget) { super.didUpdateWidget(oldWidget); _checkLoginState(); if (_recipeService != null) { _loadRecipes(fetchFromNostr: false); } } void _checkLoginState() { final sessionService = ServiceLocator.instance.sessionService; final isLoggedIn = sessionService?.isLoggedIn ?? false; if (isLoggedIn != _wasLoggedIn) { _wasLoggedIn = isLoggedIn; if (mounted) { setState(() {}); if (isLoggedIn && _recipeService != null) { _loadRecipes(fetchFromNostr: true); } } } } Future _initializeService() async { try { _recipeService = ServiceLocator.instance.recipeService; if (_recipeService == null) { throw Exception('RecipeService not available in ServiceLocator'); } _loadRecipes(fetchFromNostr: false); } catch (e) { Logger.error('Failed to initialize RecipeService', e); if (mounted) { setState(() { _errorMessage = 'Failed to initialize recipe service: $e'; _isLoading = false; }); } } } void refreshFromNostr() { if (_recipeService != null) { _loadRecipes(fetchFromNostr: true); } } Future _loadRecipes({bool fetchFromNostr = false, bool clearImageCache = false, bool showLoading = true}) async { if (_recipeService == null) return; if (showLoading) { setState(() { _isLoading = true; _errorMessage = null; }); } try { // Clear image cache if requested (e.g., on pull-to-refresh) if (clearImageCache) { final mediaService = ServiceLocator.instance.mediaService; if (mediaService != null) { try { // Clear cache for all recipe images final recipes = await _recipeService!.getAllRecipes(); for (final recipe in recipes) { for (final imageUrl in recipe.imageUrls) { try { await mediaService.clearImageCache(imageUrl); } catch (e) { // Ignore individual cache clear failures Logger.debug('Failed to clear cache for $imageUrl: $e'); } } } Logger.info('Cleared image cache for ${recipes.length} recipe(s)'); } catch (e) { Logger.warning('Failed to clear image cache: $e'); // Don't fail the load if cache clearing fails } } } if (fetchFromNostr) { final sessionService = ServiceLocator.instance.sessionService; if (sessionService != null && sessionService.currentUser != null) { final user = sessionService.currentUser!; if (user.nostrProfile != null || user.nostrPrivateKey != null) { try { final publicKey = user.id; final count = await _recipeService!.fetchRecipesFromNostr(publicKey); Logger.info('Fetched $count recipe(s) from Nostr'); } catch (e) { Logger.warning('Failed to fetch recipes from Nostr: $e'); } } } } final recipes = await _recipeService!.getAllRecipes(); Logger.info('RecipesScreen: Loaded ${recipes.length} recipe(s) from local database'); if (mounted) { setState(() { _recipes = recipes; _filteredRecipes = List.from(recipes); if (showLoading) { _isLoading = false; } }); _filterRecipes(); // Apply current search filter } } catch (e) { Logger.error('Failed to load recipes', e); if (mounted) { setState(() { _errorMessage = 'Failed to load recipes: $e'; _isLoading = false; }); } } } void _viewRecipe(RecipeModel recipe) async { await Navigator.of(context).push( MaterialPageRoute( builder: (context) => AddRecipeScreen(recipe: recipe, viewMode: true), ), ); // Always reload recipes after viewing/editing to ensure UI is up-to-date // Don't fetch from Nostr immediately after editing - relays may not have synced yet // The local database already has the updated recipe, so just reload from local // Skip loading indicator for instant UI update if (mounted) { await _loadRecipes(fetchFromNostr: false, showLoading: false); } } Future _toggleFavorite(RecipeModel recipe) async { if (_recipeService == null) return; try { final updatedRecipe = recipe.copyWith(isFavourite: !recipe.isFavourite); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { _loadRecipes(); } } 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, ), ); } } } Future _showBookmarkDialog(RecipeModel recipe) async { try { final recipeService = _recipeService; if (recipeService == null) return; final currentCategories = await recipeService.getCategoriesForRecipe(recipe.id); final result = await showDialog( context: context, builder: (context) => BookmarkDialog( recipeId: recipe.id, currentCategories: currentCategories, ), ); if (result != null && mounted) { // Reload recipes to reflect bookmark changes await _loadRecipes(fetchFromNostr: false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Bookmark updated'), duration: Duration(milliseconds: 800), ), ); } } catch (e) { Logger.error('Failed to show bookmark dialog', e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to update bookmark: $e'), backgroundColor: Colors.red, ), ); } } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: _isSearching ? TextField( controller: _searchController, autofocus: true, decoration: const InputDecoration( hintText: 'Search recipes...', border: InputBorder.none, hintStyle: TextStyle(color: Colors.white70), ), style: const TextStyle(color: Colors.white), onChanged: (_) => _filterRecipes(), ) : const Text('All Recipes'), elevation: 0, bottom: _buildTagFilterBar(), actions: [ IconButton( icon: Icon(_isSearching ? Icons.close : Icons.search), onPressed: () { setState(() { _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); // Clear selected tags when closing search _selectedTags.clear(); _filterRecipes(); } }); }, tooltip: 'Search', ), IconButton( icon: const Icon(Icons.favorite), onPressed: () async { // Navigate to Favourites screen and refresh if changes were made final scaffold = context.findAncestorStateOfType(); final hasChanges = await scaffold?.navigateToFavourites() ?? false; // Refresh recipes list if any changes were made in Favourites screen if (hasChanges && mounted && _recipeService != null) { await _loadRecipes(fetchFromNostr: false, showLoading: false); } }, tooltip: 'Favourites', ), IconButton( icon: Icon(_isMinimalView ? Icons.grid_view : Icons.view_list), onPressed: () async { final newValue = !_isMinimalView; setState(() { _isMinimalView = newValue; }); await _savePreferences(); }, ), ], ), body: _buildBody(), ); } PreferredSizeWidget _buildTagFilterBar() { final allTags = _getFilteredTags().toList()..sort(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return PreferredSize( preferredSize: const Size.fromHeight(60), child: Container( color: theme.scaffoldBackgroundColor, padding: const EdgeInsets.symmetric(vertical: 8), height: 60, child: allTags.isEmpty ? Center( child: Text( 'No tags available', style: theme.textTheme.bodySmall?.copyWith( color: isDark ? Colors.grey[400] : Colors.grey[600], ), ), ) : NotificationListener( onNotification: (notification) { // Detect when user manually scrolls - stop auto-scroll permanently if (notification is ScrollStartNotification && notification.dragDetails != null) { _isUserScrollingTags = true; _tagAnimationController?.stop(); } return false; }, child: ListView.builder( controller: _tagScrollController ?? ScrollController(), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: allTags.length, itemBuilder: (context, index) { final tag = allTags[index]; final isSelected = _selectedTags.contains(tag); return Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( label: Text( tag, style: TextStyle( color: isSelected ? Colors.white : (isDark ? Colors.white : Colors.black87), fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), selected: isSelected, onSelected: (selected) { _selectTag(tag); }, selectedColor: theme.primaryColor, checkmarkColor: Colors.white, backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], side: isSelected ? BorderSide( color: theme.primaryColor.withValues(alpha: 0.8), width: 1.5, ) : null, ), ); }, ), ), ), ); } 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 recipes', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey.shade700, ), 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: _loadRecipes, child: const Text('Retry'), ), ], ), ), ); } return RefreshIndicator( onRefresh: () => _loadRecipes(fetchFromNostr: true, clearImageCache: true), child: _filteredRecipes.isEmpty ? SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: SizedBox( height: MediaQuery.of(context).size.height - 200, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.restaurant_menu, size: 64, color: Colors.grey[400], ), const SizedBox(height: 16), Text( _searchController.text.isEmpty ? 'No recipes yet' : 'No recipes found', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey[600], ), ), const SizedBox(height: 8), Text( _searchController.text.isEmpty ? 'Tap the + button to create your first recipe' : 'Try a different search term', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[500], ), ), ], ), ), ), ) : ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), itemCount: _filteredRecipes.length, itemBuilder: (context, index) { final recipe = _filteredRecipes[index]; return _RecipeCard( recipe: recipe, isMinimal: _isMinimalView, onTap: () => _viewRecipe(recipe), onFavouriteToggle: () => _toggleFavorite(recipe), onBookmarkToggle: () => _showBookmarkDialog(recipe), ); }, ), ); } } class _RecipeCard extends StatelessWidget { final RecipeModel recipe; final bool isMinimal; final VoidCallback onTap; final VoidCallback onFavouriteToggle; final VoidCallback onBookmarkToggle; const _RecipeCard({ required this.recipe, required this.isMinimal, required this.onTap, required this.onFavouriteToggle, required this.onBookmarkToggle, }); Future _isRecipeBookmarked(String recipeId) async { try { final recipeService = ServiceLocator.instance.recipeService; if (recipeService != null) { return await recipeService.isRecipeBookmarked(recipeId); } } catch (e) { Logger.warning('Failed to check bookmark status: $e'); } return false; } 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, ), ), ); } @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: Action icons Row( mainAxisSize: MainAxisSize.min, children: [ // 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, ), ), ), const SizedBox(width: 8), // Bookmark icon FutureBuilder( future: _isRecipeBookmarked(recipe.id), builder: (context, snapshot) { final isBookmarked = snapshot.data ?? false; return InkWell( onTap: onBookmarkToggle, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(4), child: Icon( isBookmarked ? Icons.bookmark : Icons.bookmark_border, color: isBookmarked ? Colors.blue : 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: 4), // Bookmark icon FutureBuilder( future: _isRecipeBookmarked(recipe.id), builder: (context, snapshot) { final isBookmarked = snapshot.data ?? false; return InkWell( onTap: onBookmarkToggle, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(6), child: Icon( isBookmarked ? Icons.bookmark : Icons.bookmark_border, color: isBookmarked ? Colors.blue : 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 Image.network(imageUrl, fit: BoxFit.cover); } // 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.image_not_supported, size: 32, color: Colors.grey), ); } 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.image_not_supported, size: 32, color: Colors.grey), ); }, 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, ), ), ); }, ); } Widget _buildVideoThumbnail(String videoUrl) { return _VideoThumbnailPreview(videoUrl: videoUrl); } Color _getRatingColor(int rating) { if (rating >= 4) return Colors.green; if (rating >= 2) return Colors.orange; return Colors.red; } } /// 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, ), ), ), ], ); } }