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/nostr/models/nostr_keypair.dart'; import '../../data/immich/immich_service.dart'; import '../shared/primary_app_bar.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(); } class _RecipesScreenState extends State { List _recipes = []; bool _isLoading = false; String? _errorMessage; RecipeService? _recipeService; @override void initState() { super.initState(); _initializeService(); } @override void didChangeDependencies() { super.didChangeDependencies(); // Reload recipes when returning to this screen if (_recipeService != null) { _loadRecipes(); } } Future _initializeService() async { try { final localStorage = ServiceLocator.instance.localStorageService; final nostrService = ServiceLocator.instance.nostrService; final sessionService = ServiceLocator.instance.sessionService; // Get Nostr keypair from session if available NostrKeyPair? nostrKeyPair; if (sessionService != null && sessionService.currentUser != null) { final user = sessionService.currentUser!; if (user.nostrPrivateKey != null) { try { nostrKeyPair = NostrKeyPair.fromNsec(user.nostrPrivateKey!); } catch (e) { Logger.warning('Failed to parse Nostr keypair from session: $e'); } } } _recipeService = RecipeService( localStorage: localStorage, nostrService: nostrService, nostrKeyPair: nostrKeyPair, ); await _recipeService!.initialize(); _loadRecipes(); } catch (e) { Logger.error('Failed to initialize RecipeService', e); if (mounted) { setState(() { _errorMessage = 'Failed to initialize recipe service: $e'; _isLoading = false; }); } } } Future _loadRecipes() async { if (_recipeService == null) return; setState(() { _isLoading = true; _errorMessage = null; }); try { final recipes = await _recipeService!.getAllRecipes(); 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 _editRecipe(RecipeModel recipe) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => AddRecipeScreen(recipe: recipe), ), ).then((_) { // Reload recipes after editing _loadRecipes(); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: PrimaryAppBar(title: 'Recipes'), body: _buildBody(), ); } Widget _buildBody() { 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, child: ListView.builder( padding: const EdgeInsets.all(16), itemCount: _recipes.length, itemBuilder: (context, index) { final recipe = _recipes[index]; return _RecipeCard( recipe: recipe, onEdit: () => _editRecipe(recipe), onDelete: () => _deleteRecipe(recipe), ); }, ), ); } } /// Card widget for displaying a recipe. class _RecipeCard extends StatelessWidget { final RecipeModel recipe; final VoidCallback onEdit; final VoidCallback onDelete; const _RecipeCard({ required this.recipe, 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, ), ), ); }, ); } @override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.only(bottom: 16), elevation: 2, child: InkWell( onTap: onEdit, borderRadius: BorderRadius.circular(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Image section if (recipe.imageUrls.isNotEmpty) ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: AspectRatio( aspectRatio: 16 / 9, child: Stack( fit: StackFit.expand, children: [ _buildRecipeImage(recipe.imageUrls.first), if (recipe.isFavourite) Positioned( top: 8, right: 8, child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.red.withOpacity(0.8), shape: BoxShape.circle, ), child: const Icon( Icons.favorite, color: Colors.white, size: 20, ), ), ), ], ), ), ), // 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: const Icon(Icons.edit), onPressed: onEdit, tooltip: 'Edit', ), IconButton( icon: const Icon(Icons.delete), onPressed: onDelete, tooltip: 'Delete', color: Colors.red, ), ], ), ], ), ), ], ), ), ); } }