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'; /// 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 = []; 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(); // Check if login state changed and reload if needed _checkLoginState(); // Reload recipes when returning to this screen // Fetch from Nostr on first load to ensure we have the latest data if (_recipeService != null) { _loadRecipes(fetchFromNostr: false); // Don't fetch from Nostr on every dependency change } } @override void didUpdateWidget(RecipesScreen oldWidget) { super.didUpdateWidget(oldWidget); // Check if login state changed _checkLoginState(); // Offline-first: Only reload from local DB when widget updates // Nostr fetch happens only on explicit refresh (pull-to-refresh or tab tap) if (_recipeService != null) { _loadRecipes(fetchFromNostr: false); } } void _checkLoginState() { final sessionService = ServiceLocator.instance.sessionService; final isLoggedIn = sessionService?.isLoggedIn ?? false; // If login state changed, rebuild the widget if (isLoggedIn != _wasLoggedIn) { _wasLoggedIn = isLoggedIn; if (mounted) { setState(() { // Trigger rebuild to show/hide login prompt }); // If just logged in, load recipes if (isLoggedIn && _recipeService != null) { _loadRecipes(fetchFromNostr: true); } } } } Future _initializeService() async { try { // Use the shared RecipeService from ServiceLocator instead of creating a new instance // This ensures we're using the same instance that SessionService manages _recipeService = ServiceLocator.instance.recipeService; if (_recipeService == null) { throw Exception('RecipeService not available in ServiceLocator'); } // Ensure the service is initialized (it should be, but check anyway) // The database should already be initialized by SessionService on login // If not initialized, we'll get an error when trying to use it, which is fine // Load recipes from local DB only (offline-first) // Nostr fetch only happens on pull-to-refresh _loadRecipes(fetchFromNostr: false); } catch (e) { Logger.error('Failed to initialize RecipeService', e); if (mounted) { setState(() { _errorMessage = 'Failed to initialize recipe service: $e'; _isLoading = false; }); } } } /// Public method to refresh recipes from Nostr. /// Can be called externally (e.g., when tab is tapped). void refreshFromNostr() { if (_recipeService != null) { _loadRecipes(fetchFromNostr: true); } } Future _loadRecipes({bool fetchFromNostr = false}) async { if (_recipeService == null) return; setState(() { _isLoading = true; _errorMessage = null; }); try { // Fetch from Nostr if requested (e.g., on pull-to-refresh or initial load) if (fetchFromNostr) { final sessionService = ServiceLocator.instance.sessionService; if (sessionService != null && sessionService.currentUser != null) { final user = sessionService.currentUser!; // Only fetch if user has Nostr profile or key if (user.nostrProfile != null || user.nostrPrivateKey != null) { try { final publicKey = user.id; // User ID is the Nostr public key 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'); // Continue to load from local DB even if fetch fails } } } } // Load recipes from local database final recipes = await _recipeService!.getAllRecipes(); Logger.info('RecipesScreen: Loaded ${recipes.length} recipe(s) from local database'); if (mounted) { setState(() { _recipes = recipes; _isLoading = false; }); } } catch (e) { Logger.error('Failed to load recipes', e); if (mounted) { setState(() { _errorMessage = 'Failed to load recipes: $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, ), ); _loadRecipes(); } } 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 recipes after viewing (in case favorite was toggled) if (mounted) { _loadRecipes(); } } void _editRecipe(RecipeModel recipe) async { final result = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => AddRecipeScreen(recipe: recipe), ), ); // Reload recipes after editing (result indicates if recipe was saved) if (result == true || 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, ), ); } } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Recipes'), 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() { // Check if user is logged in final sessionService = ServiceLocator.instance.sessionService; final isLoggedIn = sessionService?.isLoggedIn ?? false; // Show login prompt if not logged in 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, ), const SizedBox(height: 8), Text( 'Recipes are associated with your user account', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), textAlign: TextAlign.center, ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: () { // Navigate to User/Session tab (index 3 in bottom nav) // This is handled by the parent MainNavigationScaffold }, icon: const Icon(Icons.person), label: const Text('Go to Login'), ), ], ), ), ); } 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'), ), ], ), ), ); } if (_recipes.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.menu_book, size: 64, color: Colors.grey.shade400), const SizedBox(height: 16), Text( 'No recipes yet', style: TextStyle( fontSize: 18, color: Colors.grey.shade600, ), ), const SizedBox(height: 8), Text( 'Tap the + button to add your first recipe', style: TextStyle( fontSize: 14, color: Colors.grey.shade500, ), ), ], ), ); } return RefreshIndicator( onRefresh: () => _loadRecipes(fetchFromNostr: true), 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. 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, }); /// Builds an image widget using ImmichService for authenticated access. Widget _buildRecipeImage(String imageUrl) { final immichService = ServiceLocator.instance.immichService; // Try to extract asset ID from URL (format: .../api/assets/{id}/original) final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); if (assetIdMatch != null && immichService != null) { final assetId = assetIdMatch.group(1); if (assetId != null) { // Use ImmichService to fetch image with proper authentication 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, ); }, ); } } // Fallback to direct network image if not an Immich URL or service unavailable return Image.network( imageUrl, fit: BoxFit.cover, 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(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: _buildRecipeImage(imageUrls.first), ), ); } // Mosaic layout for multiple images - no gaps between images return ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final height = width * 0.6; // 3:5 aspect ratio for mosaic if (imageUrls.length == 2) { // Two images side by side - no gap return SizedBox( height: height, child: Row( children: [ Expanded( child: ClipRect( child: _buildRecipeImage(imageUrls[0]), ), ), Expanded( child: ClipRect( child: _buildRecipeImage(imageUrls[1]), ), ), ], ), ); } else if (imageUrls.length == 3) { // One large on left, two stacked on right - no gap return SizedBox( height: height, child: Row( children: [ Expanded( flex: 2, child: ClipRect( child: _buildRecipeImage(imageUrls[0]), ), ), Expanded( child: Column( children: [ Expanded( child: ClipRect( child: _buildRecipeImage(imageUrls[1]), ), ), Expanded( child: ClipRect( child: _buildRecipeImage(imageUrls[2]), ), ), ], ), ), ], ), ); } else { // 4+ images: 2x2 grid (or more) - no gap 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 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: [ // Compact image if (recipe.imageUrls.isNotEmpty) ClipRRect( borderRadius: BorderRadius.circular(6), child: SizedBox( width: 80, height: 80, child: _buildRecipeImage(recipe.imageUrls.first), ), ), if (recipe.imageUrls.isNotEmpty) const SizedBox(width: 12), // Compact content 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, ), ), if (recipe.rating > 0) Row( mainAxisSize: MainAxisSize.min, children: List.generate(5, (index) { return Icon( index < recipe.rating ? Icons.star : Icons.star_border, size: 12, color: index < recipe.rating ? Colors.amber : Colors.grey, ); }), ), ], ), if (recipe.tags.isNotEmpty) ...[ const SizedBox(height: 4), Wrap( spacing: 4, runSpacing: 4, children: recipe.tags.take(2).map((tag) { return Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: Colors.grey.shade200, borderRadius: BorderRadius.circular(8), ), child: Text( tag, style: const TextStyle(fontSize: 10), ), ); }).toList(), ), ], ], ), ), // Favorite button IconButton( icon: Icon( recipe.isFavourite ? Icons.favorite : Icons.favorite_border, size: 20, color: recipe.isFavourite ? Colors.red : Colors.grey, ), onPressed: onFavorite, tooltip: recipe.isFavourite ? 'Remove from favorites' : 'Add to favorites', ), // Compact actions 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: [ // Image section with mosaic for multiple images if (recipe.imageUrls.isNotEmpty) _buildMosaicImages(recipe.imageUrls), // Content section 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, ), ], ), ], ), ), ], ), ), ); } }