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'; /// 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 _openPhotoGallery(List imageUrls, int initialIndex) { if (imageUrls.isEmpty) return; Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: imageUrls, initialIndex: initialIndex, ), ), ); } @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, onRecipeTap: _viewRecipe, onPhotoTap: _openPhotoGallery, ); }, childCount: filteredCategories.length, ), ), ), ], ), ), ); } } /// Modern category card widget class _CategoryCard extends StatefulWidget { final BookmarkCategory category; final List recipes; final Color categoryColor; final Function(RecipeModel) onRecipeTap; final Function(List, int) onPhotoTap; const _CategoryCard({ required this.category, required this.recipes, required this.categoryColor, required this.onRecipeTap, required this.onPhotoTap, }); @override State<_CategoryCard> createState() => _CategoryCardState(); } class _CategoryCardState extends State<_CategoryCard> { bool _isExpanded = false; @override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.only(bottom: 16), elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Column( children: [ InkWell( onTap: () { setState(() { _isExpanded = !_isExpanded; }); }, borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ // Category icon/color indicator Container( width: 48, height: 48, decoration: BoxDecoration( color: widget.categoryColor.withOpacity(0.2), borderRadius: BorderRadius.circular(12), border: Border.all( color: widget.categoryColor, width: 2, ), ), child: Icon( Icons.bookmark, color: widget.categoryColor, size: 24, ), ), const SizedBox(width: 16), // Category info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.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( '${widget.recipes.length} recipe${widget.recipes.length != 1 ? 's' : ''}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), ), ], ), ], ), ), // Expand icon Icon( _isExpanded ? Icons.expand_less : Icons.expand_more, color: Colors.grey.shade600, ), ], ), ), ), // Recipes list if (_isExpanded) AnimatedSize( duration: const Duration(milliseconds: 200), child: widget.recipes.isEmpty ? Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Icon( Icons.info_outline, size: 20, color: Colors.grey.shade400, ), const SizedBox(width: 8), Text( 'No recipes in this category', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), ), ], ), ) : Column( children: widget.recipes.map((recipe) { return _BookmarkRecipeItem( recipe: recipe, onTap: () => widget.onRecipeTap(recipe), onPhotoTap: (index) => widget.onPhotoTap( recipe.imageUrls, index, ), ); }).toList(), ), ), ], ), ); } } /// 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, ), ], ), ), ); } }