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 '../navigation/main_navigation_scaffold.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 with SingleTickerProviderStateMixin { List _categories = []; Map> _recipesByCategory = {}; bool _isLoading = false; String? _errorMessage; RecipeService? _recipeService; bool _wasLoggedIn = false; String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); bool _isSearching = false; Set _selectedTags = {}; // Currently selected tags for filtering // Auto-scroll animation for tag filter bar ScrollController? _tagScrollController; AnimationController? _tagAnimationController; Animation? _tagAnimation; bool _isUserScrollingTags = false; @override void initState() { super.initState(); _checkLoginState(); _initializeService(); _searchController.addListener(() { setState(() { _searchQuery = _searchController.text.toLowerCase(); }); }); // Initialize tag scroll animation _tagScrollController = ScrollController(); _tagAnimationController = AnimationController( vsync: this, duration: const Duration(seconds: 8), ); _tagAnimation = Tween(begin: 0, end: 0.3).animate( CurvedAnimation(parent: _tagAnimationController!, curve: Curves.easeInOut), ); _startTagAutoScroll(); _tagAnimation?.addListener(_animateTagScroll); } @override void dispose() { _searchController.dispose(); _tagAnimationController?.dispose(); _tagScrollController?.dispose(); super.dispose(); } void _startTagAutoScroll() { if (!_isUserScrollingTags && _tagAnimationController != null) { _tagAnimationController!.repeat(reverse: true); } } void _animateTagScroll() { if (!_isUserScrollingTags && _tagScrollController != null && _tagScrollController!.hasClients && _tagAnimation != null) { final allTags = _getFilteredTags().toList(); if (allTags.length > 3) { final maxScroll = _tagScrollController!.position.maxScrollExtent; if (maxScroll > 0) { final targetScroll = _tagAnimation!.value * maxScroll; if ((_tagScrollController!.offset - targetScroll).abs() > 1) { _tagScrollController!.jumpTo(targetScroll); } } } } } @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; } } Set _getAllTags() { final tags = {}; for (final recipes in _recipesByCategory.values) { for (final recipe in recipes) { tags.addAll(recipe.tags); } } return tags; } /// Gets tags from recipes that match the current search query Set _getFilteredTags() { final query = _searchController.text.toLowerCase(); final tags = {}; // If there's a search query, only get tags from matching recipes if (query.isNotEmpty) { for (final recipes in _recipesByCategory.values) { for (final recipe in recipes) { final titleMatch = recipe.title.toLowerCase().contains(query); final descriptionMatch = recipe.description?.toLowerCase().contains(query) ?? false; final tagsMatch = recipe.tags.any((tag) => tag.toLowerCase().contains(query)); if (titleMatch || descriptionMatch || tagsMatch) { tags.addAll(recipe.tags); } } } } else { // If no search query, return all tags return _getAllTags(); } return tags; } /// Handles tag selection for filtering void _selectTag(String tag) { // Stop auto-scroll permanently when user selects a tag _isUserScrollingTags = true; _tagAnimationController?.stop(); setState(() { if (_selectedTags.contains(tag)) { _selectedTags.remove(tag); } else { _selectedTags.add(tag); } // Automatically activate search form when tags are selected if (_selectedTags.isNotEmpty && !_isSearching) { _isSearching = true; } }); } List _getFilteredCategories() { final query = _searchQuery; if (query.isEmpty && _selectedTags.isEmpty) { return _categories; } return _categories.where((category) { final recipes = _recipesByCategory[category.id] ?? []; // Filter by search query final matchesQuery = query.isEmpty || category.name.toLowerCase().contains(query) || recipes.any((recipe) => recipe.title.toLowerCase().contains(query) || (recipe.description?.toLowerCase().contains(query) ?? false)); // Filter by selected tags if (_selectedTags.isNotEmpty) { final matchesTags = recipes.any((recipe) => _selectedTags.every((tag) => recipe.tags.contains(tag))); return matchesQuery && matchesTags; } return matchesQuery; }).toList(); } List _getFilteredRecipes(String categoryId) { final recipes = _recipesByCategory[categoryId] ?? []; final query = _searchQuery; if (query.isEmpty && _selectedTags.isEmpty) { return recipes; } return recipes.where((recipe) { // Filter by search query final matchesQuery = query.isEmpty || recipe.title.toLowerCase().contains(query) || (recipe.description?.toLowerCase().contains(query) ?? false); // Filter by selected tags if (_selectedTags.isNotEmpty) { final matchesTags = _selectedTags.every((tag) => recipe.tags.contains(tag)); return matchesQuery && matchesTags; } return matchesQuery; }).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(); }); } PreferredSizeWidget _buildTagFilterBar() { final allTags = _getFilteredTags().toList()..sort(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return PreferredSize( preferredSize: const Size.fromHeight(60), child: Container( color: theme.scaffoldBackgroundColor, padding: const EdgeInsets.symmetric(vertical: 8), height: 60, child: allTags.isEmpty ? Center( child: Text( 'No tags available', style: theme.textTheme.bodySmall?.copyWith( color: isDark ? Colors.grey[400] : Colors.grey[600], ), ), ) : NotificationListener( onNotification: (notification) { // Detect when user manually scrolls - stop auto-scroll permanently if (notification is ScrollStartNotification && notification.dragDetails != null) { _isUserScrollingTags = true; _tagAnimationController?.stop(); } return false; }, child: ListView.builder( controller: _tagScrollController ?? ScrollController(), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: allTags.length, itemBuilder: (context, index) { final tag = allTags[index]; final isSelected = _selectedTags.contains(tag); return Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( label: Text( tag, style: TextStyle( color: isSelected ? Colors.white : (isDark ? Colors.white : Colors.black87), fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), selected: isSelected, onSelected: (selected) { _selectTag(tag); }, selectedColor: theme.primaryColor, checkmarkColor: Colors.white, backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], side: isSelected ? BorderSide( color: theme.primaryColor.withValues(alpha: 0.8), width: 1.5, ) : null, ), ); }, ), ), ), ); } 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( toolbarHeight: (_isSearching && _selectedTags.isNotEmpty) ? 80 : null, title: _isSearching ? Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), child: TextField( controller: _searchController, autofocus: true, decoration: const InputDecoration( hintText: 'Search bookmarks...', border: InputBorder.none, hintStyle: TextStyle(color: Colors.white70), contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 8), ), style: const TextStyle(color: Colors.white), ), ), if (_selectedTags.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2, bottom: 4, left: 4, right: 4), child: RichText( maxLines: 1, overflow: TextOverflow.ellipsis, text: TextSpan( style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w400, ), children: [ const TextSpan( text: 'Tag: ', style: TextStyle(color: Colors.grey), ), TextSpan( text: _selectedTags.join(', '), style: const TextStyle(color: Colors.green), ), ], ), ), ), ], ) : const Text('Bookmarks'), titleSpacing: _isSearching ? 0 : null, elevation: 0, bottom: _buildTagFilterBar(), actions: [ IconButton( icon: Icon(_isSearching ? Icons.close : Icons.search), onPressed: () { setState(() { _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); // Clear selected tags when closing search _selectedTags.clear(); } }); }, tooltip: 'Search', ), IconButton( icon: const Icon(Icons.favorite), onPressed: () async { // Navigate to Favourites screen and refresh if changes were made final scaffold = context.findAncestorStateOfType(); final hasChanges = await scaffold?.navigateToFavourites() ?? false; // Refresh bookmarks list if any changes were made in Favourites screen if (hasChanges && mounted && _recipeService != null) { await _loadBookmarks(); } }, tooltip: 'Favourites', ), ], ), body: RefreshIndicator( onRefresh: _loadBookmarks, child: CustomScrollView( slivers: [ // Categories list if (filteredCategories.isEmpty && (_searchQuery.isNotEmpty || _selectedTags.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 bookmarks 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.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), border: Border.all( color: categoryColor, width: 2, ), boxShadow: Theme.of(context).brightness == Brightness.dark ? [ BoxShadow( color: categoryColor.withValues(alpha: 0.3), blurRadius: 8, spreadRadius: 1, ), ] : null, ), child: Icon( Icons.bookmark, color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withValues(alpha: 0.95) : 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, ), ], ), ), ); } }