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/bookmark_category_model.dart'; import '../../data/recipes/models/recipe_model.dart'; import '../add_recipe/add_recipe_screen.dart'; import '../photo_gallery/photo_gallery_screen.dart'; import 'bookmark_category_recipes_screen.dart'; /// Bookmarks screen displaying all bookmark categories and their recipes. class BookmarksScreen extends StatefulWidget { const BookmarksScreen({super.key}); @override State createState() => _BookmarksScreenState(); } class _BookmarksScreenState extends State { List _categories = []; Map> _recipesByCategory = {}; bool _isLoading = false; String? _errorMessage; RecipeService? _recipeService; bool _wasLoggedIn = false; String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); _checkLoginState(); _initializeService(); _searchController.addListener(() { setState(() { _searchQuery = _searchController.text.toLowerCase(); }); }); } @override void dispose() { _searchController.dispose(); super.dispose(); } @override void didChangeDependencies() { super.didChangeDependencies(); _checkLoginState(); if (_recipeService != null && _wasLoggedIn) { _loadBookmarks(); } } @override void didUpdateWidget(BookmarksScreen oldWidget) { super.didUpdateWidget(oldWidget); _checkLoginState(); if (_recipeService != null && _wasLoggedIn) { _loadBookmarks(); } } void _checkLoginState() { final sessionService = ServiceLocator.instance.sessionService; final isLoggedIn = sessionService?.isLoggedIn ?? false; if (isLoggedIn != _wasLoggedIn) { _wasLoggedIn = isLoggedIn; if (mounted) { setState(() {}); if (isLoggedIn && _recipeService != null) { _loadBookmarks(); } } } } Future _initializeService() async { try { _recipeService = ServiceLocator.instance.recipeService; if (_recipeService == null) { throw Exception('RecipeService not available in ServiceLocator'); } } catch (e) { Logger.error('Failed to initialize RecipeService', e); if (mounted) { setState(() { _errorMessage = 'Failed to initialize recipe service: $e'; }); } } } Future _loadBookmarks() async { if (_recipeService == null) return; setState(() { _isLoading = true; _errorMessage = null; }); try { final categories = await _recipeService!.getAllBookmarkCategories(); final recipesMap = >{}; for (final category in categories) { final recipes = await _recipeService!.getRecipesByCategory(category.id); recipesMap[category.id] = recipes; } if (mounted) { setState(() { _categories = categories; _recipesByCategory = recipesMap; _isLoading = false; }); } } catch (e) { Logger.error('Failed to load bookmarks', e); if (mounted) { setState(() { _isLoading = false; _errorMessage = 'Failed to load bookmarks: $e'; }); } } } Color _parseColor(String hexColor) { try { return Color(int.parse(hexColor.replaceAll('#', '0xFF'))); } catch (e) { return Colors.blue; } } List _getFilteredCategories() { if (_searchQuery.isEmpty) { return _categories; } return _categories.where((category) { final recipes = _recipesByCategory[category.id] ?? []; return category.name.toLowerCase().contains(_searchQuery) || recipes.any((recipe) => recipe.title.toLowerCase().contains(_searchQuery) || (recipe.description?.toLowerCase().contains(_searchQuery) ?? false)); }).toList(); } List _getFilteredRecipes(String categoryId) { final recipes = _recipesByCategory[categoryId] ?? []; if (_searchQuery.isEmpty) { return recipes; } return recipes.where((recipe) => recipe.title.toLowerCase().contains(_searchQuery) || (recipe.description?.toLowerCase().contains(_searchQuery) ?? false)).toList(); } void _viewRecipe(RecipeModel recipe) { Navigator.push( context, MaterialPageRoute( builder: (context) => AddRecipeScreen( recipe: recipe, viewMode: true, ), ), ).then((_) { // Reload bookmarks when returning from recipe view _loadBookmarks(); }); } void _openCategory(BookmarkCategory category) { Navigator.push( context, MaterialPageRoute( builder: (context) => BookmarkCategoryRecipesScreen( category: category, ), ), ).then((_) { // Reload bookmarks when returning from category view _loadBookmarks(); }); } Future _deleteCategory(BookmarkCategory category) async { final recipes = _recipesByCategory[category.id] ?? []; final recipeCount = recipes.length; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete Bookmark Category'), content: Text( recipeCount > 0 ? 'Are you sure you want to delete "${category.name}"? This will remove this bookmark from all $recipeCount recipe${recipeCount != 1 ? 's' : ''} that have it.' : 'Are you sure you want to delete "${category.name}"?', ), 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 && _recipeService != null) { try { await _recipeService!.deleteBookmarkCategory(category.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( recipeCount > 0 ? 'Bookmark removed from $recipeCount recipe${recipeCount != 1 ? 's' : ''}' : 'Bookmark category deleted', ), backgroundColor: Colors.green, ), ); // Reload bookmarks to reflect the deletion _loadBookmarks(); } } catch (e) { Logger.error('Failed to delete bookmark category', e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to delete bookmark category: $e'), backgroundColor: Colors.red, ), ); } } } } @override Widget build(BuildContext context) { final isLoggedIn = ServiceLocator.instance.sessionService?.isLoggedIn ?? false; if (!isLoggedIn) { return Scaffold( appBar: AppBar(title: const Text('Bookmarks')), body: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.bookmark_border, size: 80, color: Colors.grey.shade400, ), const SizedBox(height: 24), Text( 'Please log in to view bookmarks', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey.shade700, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( 'Sign in to save and organize your favorite recipes', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), textAlign: TextAlign.center, ), ], ), ), ), ); } if (_isLoading) { return Scaffold( appBar: AppBar(title: const Text('Bookmarks')), body: const Center(child: CircularProgressIndicator()), ); } if (_errorMessage != null) { return Scaffold( appBar: AppBar(title: const Text('Bookmarks')), body: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.error_outline, size: 64, color: Colors.red.shade300, ), const SizedBox(height: 16), Text( 'Oops! Something went wrong', style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( _errorMessage!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), textAlign: TextAlign.center, ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: _loadBookmarks, icon: const Icon(Icons.refresh), label: const Text('Retry'), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), ], ), ), ), ); } if (_categories.isEmpty) { return Scaffold( appBar: AppBar(title: const Text('Bookmarks')), body: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.bookmark_add_outlined, size: 100, color: Colors.grey.shade300, ), const SizedBox(height: 24), Text( 'No bookmark categories yet', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, color: Colors.grey.shade700, ), textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( 'Bookmark a recipe to organize your favorites into categories', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Colors.grey.shade600, ), textAlign: TextAlign.center, ), ], ), ), ), ); } final filteredCategories = _getFilteredCategories(); return Scaffold( appBar: AppBar( title: const Text('Bookmarks'), elevation: 0, ), body: RefreshIndicator( onRefresh: _loadBookmarks, child: CustomScrollView( slivers: [ // Search bar SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Search bookmarks...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); }, ) : null, filled: true, fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey[800] : Colors.grey[100], border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ), ), ), // Categories list if (filteredCategories.isEmpty && _searchQuery.isNotEmpty) SliverFillRemaining( hasScrollBody: false, child: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.search_off, size: 64, color: Colors.grey.shade400, ), const SizedBox(height: 16), Text( 'No results found', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey.shade700, ), ), const SizedBox(height: 8), Text( 'Try a different search term', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), ), ], ), ), ), ) else SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final category = filteredCategories[index]; final recipes = _getFilteredRecipes(category.id); return _CategoryCard( category: category, recipes: recipes, categoryColor: category.color != null ? _parseColor(category.color!) : Theme.of(context).primaryColor, onTap: () => _openCategory(category), onDelete: () => _deleteCategory(category), ); }, childCount: filteredCategories.length, ), ), ), ], ), ), ); } } /// Modern category card widget class _CategoryCard extends StatelessWidget { final BookmarkCategory category; final List recipes; final Color categoryColor; final VoidCallback onTap; final VoidCallback onDelete; const _CategoryCard({ required this.category, required this.recipes, required this.categoryColor, required this.onTap, required this.onDelete, }); @override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.only(bottom: 16), 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( children: [ // Category icon/color indicator Container( width: 48, height: 48, decoration: BoxDecoration( color: categoryColor.withOpacity(0.2), borderRadius: BorderRadius.circular(12), border: Border.all( color: categoryColor, width: 2, ), ), child: Icon( Icons.bookmark, color: categoryColor, size: 24, ), ), const SizedBox(width: 16), // Category info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( category.name, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 4), Row( children: [ Icon( Icons.restaurant_menu, size: 16, color: Colors.grey.shade600, ), const SizedBox(width: 4), Text( '${recipes.length} recipe${recipes.length != 1 ? 's' : ''}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), ), ], ), ], ), ), // Delete button IconButton( icon: const Icon(Icons.delete_outline), color: Colors.red.shade400, onPressed: onDelete, tooltip: 'Delete bookmark category', padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), const SizedBox(width: 8), // Chevron icon Icon( Icons.chevron_right, color: Colors.grey.shade600, ), ], ), ), ), ); } } /// Widget for displaying a recipe item in a bookmark category. class _BookmarkRecipeItem extends StatelessWidget { final RecipeModel recipe; final VoidCallback onTap; final ValueChanged onPhotoTap; const _BookmarkRecipeItem({ required this.recipe, required this.onTap, required this.onPhotoTap, }); /// Builds an image widget using ImmichService for authenticated access. Widget _buildRecipeImage(String imageUrl) { final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { return Container( width: 60, height: 60, color: Colors.grey.shade200, child: const Icon(Icons.image, size: 32), ); } // 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: 32), ); } 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.broken_image, size: 32), ); }, 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(double rating) { if (rating >= 4.5) return Colors.green; if (rating >= 3.5) return Colors.orange; if (rating >= 2.5) return Colors.amber; return Colors.red; } @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Recipe image recipe.imageUrls.isNotEmpty ? GestureDetector( onTap: () => onPhotoTap(0), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Container( width: 80, height: 80, decoration: BoxDecoration( color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12), ), child: _buildRecipeImage(recipe.imageUrls.first), ), ), ) : Container( width: 80, height: 80, decoration: BoxDecoration( color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12), ), child: Icon( Icons.restaurant_menu, color: Colors.grey.shade400, size: 32, ), ), const SizedBox(width: 16), // Recipe info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( recipe.title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (recipe.description != null && recipe.description!.isNotEmpty) ...[ const SizedBox(height: 6), Text( recipe.description!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey.shade600, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], const SizedBox(height: 8), Row( children: [ // Rating badge Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: _getRatingColor(recipe.rating.toDouble()).withOpacity(0.15), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.star, size: 14, color: Colors.amber, ), const SizedBox(width: 4), Text( recipe.rating.toString(), style: Theme.of(context).textTheme.labelSmall?.copyWith( fontWeight: FontWeight.bold, color: _getRatingColor(recipe.rating.toDouble()), ), ), ], ), ), if (recipe.isFavourite) ...[ const SizedBox(width: 8), Icon( Icons.favorite, size: 16, color: Colors.red.shade400, ), ], ], ), ], ), ), // Chevron icon Icon( Icons.chevron_right, color: Colors.grey.shade400, ), ], ), ), ); } }