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 '../../data/local/models/item.dart'; import '../add_recipe/add_recipe_screen.dart'; import '../photo_gallery/photo_gallery_screen.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 { List _recipes = []; List _filteredRecipes = []; bool _isLoading = false; String? _errorMessage; RecipeService? _recipeService; bool _wasLoggedIn = false; bool _isMinimalView = false; final TextEditingController _searchController = TextEditingController(); bool _isSearching = false; @override void initState() { super.initState(); _initializeService(); _checkLoginState(); _loadPreferences(); _searchController.addListener(_onSearchChanged); } @override void dispose() { _searchController.dispose(); super.dispose(); } 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) { _filteredRecipes = List.from(_recipes); } else { _filteredRecipes = _recipes.where((recipe) { return recipe.title.toLowerCase().contains(query) || (recipe.description?.toLowerCase().contains(query) ?? false); }).toList(); } }); } @override void didChangeDependencies() { super.didChangeDependencies(); _checkLoginState(); if (_recipeService != null) { _loadRecipes(fetchFromNostr: 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}) async { if (_recipeService == null) return; setState(() { _isLoading = true; _errorMessage = null; }); try { 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); _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), ), ); if (mounted) { _loadRecipes(); } } 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, actions: [ IconButton( icon: Icon(_isSearching ? Icons.close : Icons.search), onPressed: () { setState(() { _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); _filterRecipes(); } }); }, ), IconButton( icon: Icon(_isMinimalView ? Icons.grid_view : Icons.view_list), onPressed: () async { final newValue = !_isMinimalView; setState(() { _isMinimalView = newValue; }); await _savePreferences(); }, ), ], ), 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 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), 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.all(16), 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) return; Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: recipe.imageUrls, 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.only(bottom: 12), elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ // Small thumbnail (60x60) if (recipe.imageUrls.isNotEmpty) GestureDetector( onTap: () => _openPhotoGallery(context, 0), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: SizedBox( width: 60, height: 60, child: _buildRecipeImage(recipe.imageUrls.first), ), ), ) else Container( width: 60, height: 60, decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.restaurant_menu, color: Colors.grey, ), ), const SizedBox(width: 12), // Content Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( recipe.title, style: Theme.of(context) .textTheme .titleMedium ?.copyWith( fontWeight: FontWeight.bold, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ // Bookmark icon FutureBuilder( future: _isRecipeBookmarked(recipe.id), builder: (context, snapshot) { final isBookmarked = snapshot.data ?? false; return IconButton( icon: Icon( isBookmarked ? Icons.bookmark : Icons.bookmark_border, color: isBookmarked ? Colors.blue : Colors.grey, size: 24, ), onPressed: onBookmarkToggle, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ); }, ), const SizedBox(width: 4), // Favorite icon IconButton( icon: Icon( recipe.isFavourite ? Icons.favorite : Icons.favorite_border, color: recipe.isFavourite ? Colors.red : Colors.grey, size: 24, ), onPressed: onFavouriteToggle, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), 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, ), ), ], ), ], ), ), ], ), ), ), ); } Widget _buildFullCard(BuildContext context) { // Show up to 3 images final imagesToShow = recipe.imageUrls.take(3).toList(); return Card( margin: const EdgeInsets.only(bottom: 16), elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Photo section with divided layout if (imagesToShow.isNotEmpty) ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(12), ), child: _buildPhotoLayout(context, imagesToShow), ), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( recipe.title, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 12), // Bookmark icon FutureBuilder( future: _isRecipeBookmarked(recipe.id), builder: (context, snapshot) { final isBookmarked = snapshot.data ?? false; return IconButton( icon: Icon( isBookmarked ? Icons.bookmark : Icons.bookmark_border, color: isBookmarked ? Colors.blue : Colors.grey[600], size: 20, ), onPressed: onBookmarkToggle, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ); }, ), const SizedBox(width: 4), // Favorite icon IconButton( icon: Icon( recipe.isFavourite ? Icons.favorite : Icons.favorite_border, color: recipe.isFavourite ? Colors.red : Colors.grey[600], size: 20, ), onPressed: onFavouriteToggle, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: _getRatingColor(recipe.rating).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), ), 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, ), ), ], ), ), ], ), if (recipe.description != null && recipe.description!.isNotEmpty) ...[ const SizedBox(height: 8), Text( recipe.description!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[600], ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ], ), ), ], ), ), ); } Widget _buildPhotoLayout(BuildContext context, List imagesToShow) { final imageCount = imagesToShow.length; return AspectRatio( aspectRatio: 16 / 9, child: Row( children: [ if (imageCount == 1) // Single image: full width Expanded( child: _buildImageTile(context, imagesToShow[0], 0, showBorder: false), ) else if (imageCount == 2) // Two images: split 50/50 ...imagesToShow.asMap().entries.map((entry) { final index = entry.key; return Expanded( child: _buildImageTile( context, entry.value, index, showBorder: index == 0, ), ); }) else // Three images: one large on left, two stacked on right Expanded( flex: 2, child: _buildImageTile( context, imagesToShow[0], 0, showBorder: false, ), ), if (imageCount == 3) ...[ const SizedBox(width: 2), Expanded( child: Column( children: [ Expanded( child: _buildImageTile( context, imagesToShow[1], 1, showBorder: true, ), ), const SizedBox(height: 2), Expanded( child: _buildImageTile( context, imagesToShow[2], 2, showBorder: false, ), ), ], ), ), ], ], ), ); } Widget _buildImageTile(BuildContext context, String imageUrl, int index, {required bool showBorder}) { return GestureDetector( onTap: () => _openPhotoGallery(context, index), child: Container( decoration: showBorder ? BoxDecoration( border: Border( right: BorderSide( color: Colors.white.withValues(alpha: 0.3), width: 2, ), ), ) : null, child: _buildRecipeImage(imageUrl), ), ); } 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, ), ), ); }, ); } Color _getRatingColor(int rating) { if (rating >= 4) return Colors.green; if (rating >= 2) return Colors.orange; return Colors.red; } }