diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index ba7b898..f2d3b1f 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -10,6 +11,7 @@ import '../../data/recipes/models/recipe_model.dart'; import '../../data/recipes/models/bookmark_category_model.dart'; import '../photo_gallery/photo_gallery_screen.dart'; import '../recipes/bookmark_dialog.dart'; +import '../bookmarks/bookmark_category_recipes_screen.dart'; /// Add Recipe screen for creating new recipes. class AddRecipeScreen extends StatefulWidget { @@ -57,6 +59,7 @@ class _AddRecipeScreenState extends State { List _originalBookmarkCategories = []; bool _isScrolledToBottom = false; final ScrollController _scrollController = ScrollController(); + Timer? _scrollDebounceTimer; final ImagePicker _imagePicker = ImagePicker(); RecipeService? _recipeService; @@ -116,22 +119,68 @@ class _AddRecipeScreenState extends State { await _loadBookmarkCategories(); } } + + void _navigateToBookmark() { + if (widget.recipe == null) return; + + // If recipe has bookmarks, navigate to the first one + if (_bookmarkCategories.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookmarkCategoryRecipesScreen( + category: _bookmarkCategories.first, + ), + ), + ).then((_) { + // Reload bookmark categories when returning + if (mounted && widget.viewMode) { + _loadBookmarkCategories(); + } + }); + } else { + // If no bookmarks, show dialog to add bookmarks + _showBookmarkDialog(); + } + } void _onScroll() { if (!_scrollController.hasClients) return; - final maxScroll = _scrollController.position.maxScrollExtent; - final currentScroll = _scrollController.position.pixels; + // Cancel any pending debounce timer + _scrollDebounceTimer?.cancel(); - // If content doesn't scroll (maxScroll is 0 or very small), show delete button - // Otherwise, show it only when scrolled to bottom - final isAtBottom = maxScroll <= 50 || currentScroll >= maxScroll - 50; // 50px threshold - - if (isAtBottom != _isScrolledToBottom) { - setState(() { - _isScrolledToBottom = isAtBottom; - }); - } + // Debounce the scroll update to prevent flickering + _scrollDebounceTimer = Timer(const Duration(milliseconds: 100), () { + if (!_scrollController.hasClients || !mounted) return; + + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + + // If content doesn't scroll (maxScroll is 0 or very small), show delete button + if (maxScroll <= 50) { + if (!_isScrolledToBottom) { + setState(() { + _isScrolledToBottom = true; + }); + } + return; + } + + // Use hysteresis to prevent flickering: + // - When button is hidden: show it when within 150px of bottom (easier to trigger) + // - When button is visible: hide it when more than 50px from bottom (harder to hide) + final distanceFromBottom = maxScroll - currentScroll; + final isAtBottom = _isScrolledToBottom + ? distanceFromBottom <= 50 // Hide when more than 50px away + : distanceFromBottom <= 150; // Show when within 150px + + if (isAtBottom != _isScrolledToBottom) { + setState(() { + _isScrolledToBottom = isAtBottom; + }); + } + }); } Future _initializeService() async { @@ -443,6 +492,7 @@ class _AddRecipeScreenState extends State { _descriptionController.dispose(); _tagsController.dispose(); _scrollController.dispose(); + _scrollDebounceTimer?.cancel(); super.dispose(); } @@ -1657,6 +1707,51 @@ class _AddRecipeScreenState extends State { ], ) : null, + bottomNavigationBar: widget.recipe != null + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Created date + Text( + 'Created: ${_formatDate(widget.recipe!.createdAt)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + // Delete button (only show when scrolled to bottom) + if (_isScrolledToBottom) + TextButton.icon( + onPressed: _deleteRecipe, + icon: const Icon(Icons.delete, size: 16), + label: const Text( + 'Delete', + style: TextStyle(fontSize: 12), + ), + style: TextButton.styleFrom( + foregroundColor: Colors.red[700], + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ), + ) + : null, body: CustomScrollView( controller: _scrollController, slivers: [ @@ -1684,19 +1779,35 @@ class _AddRecipeScreenState extends State { ), ), flexibleSpace: FlexibleSpaceBar( - background: RepaintBoundary( - child: mediaToShow.isEmpty - ? Container( - color: Colors.grey[200], - child: const Center( - child: Icon( - Icons.restaurant_menu, - size: 64, - color: Colors.grey, - ), - ), - ) - : _buildTiledPhotoLayout(mediaToShow), + background: Stack( + children: [ + RepaintBoundary( + child: mediaToShow.isEmpty + ? Container( + color: Colors.grey[200], + child: const Center( + child: Icon( + Icons.restaurant_menu, + size: 64, + color: Colors.grey, + ), + ), + ) + : _buildTiledPhotoLayout(mediaToShow), + ), + // Floating Add Media button + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton( + onPressed: _pickMedia, + backgroundColor: Colors.green[700], + foregroundColor: Colors.white, + heroTag: 'add_media', + child: const Icon(Icons.add_photo_alternate), + ), + ), + ], ), ), actions: [], @@ -1706,40 +1817,7 @@ class _AddRecipeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Status bar with action buttons - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor.withValues(alpha: 0.1), - width: 1, - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - // Add Media button - _buildStatusBarButton( - icon: Icons.add_photo_alternate, - label: 'Add Media', - color: Colors.green[700]!, - onTap: _pickMedia, - ), - // Delete button (only show when scrolled to bottom) - if (_isScrolledToBottom) - _buildStatusBarButton( - icon: Icons.delete, - label: 'Delete', - color: Colors.red[700]!, - onTap: _deleteRecipe, - ), - ], - ), - ), - // Tags section directly under status bar + // Tags section Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( @@ -1815,25 +1893,43 @@ class _AddRecipeScreenState extends State { spacing: 8, runSpacing: 8, children: _bookmarkCategories.map((category) { - return Chip( - label: Text( - category.name, - style: const TextStyle(fontSize: 12), - ), - avatar: category.color != null - ? Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: _parseColor(category.color!), - shape: BoxShape.circle, - ), - ) - : const Icon(Icons.bookmark, size: 14), - backgroundColor: Theme.of(context).colorScheme.secondaryContainer, - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.onSecondaryContainer, - fontSize: 12, + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookmarkCategoryRecipesScreen( + category: category, + ), + ), + ).then((_) { + // Reload bookmark categories when returning + if (mounted && widget.viewMode) { + _loadBookmarkCategories(); + } + }); + }, + borderRadius: BorderRadius.circular(16), + child: Chip( + label: Text( + category.name, + style: const TextStyle(fontSize: 12), + ), + avatar: category.color != null + ? Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: _parseColor(category.color!), + shape: BoxShape.circle, + ), + ) + : const Icon(Icons.bookmark, size: 14), + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.onSecondaryContainer, + fontSize: 12, + ), ), ); }).toList(), @@ -1921,11 +2017,11 @@ class _AddRecipeScreenState extends State { ? Colors.blue : Colors.grey[600], ), - onPressed: _showBookmarkDialog, + onPressed: _navigateToBookmark, padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: _bookmarkCategories.isNotEmpty - ? 'Manage bookmarks' + ? 'View bookmark' : 'Add to bookmarks', ), const SizedBox(width: 8), @@ -2116,14 +2212,9 @@ class _AddRecipeScreenState extends State { ), ), - // Created date + // Add bottom padding to account for fixed bottom bar if (widget.recipe != null) - Text( - 'Created: ${_formatDate(widget.recipe!.createdAt)}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - ), - ), + const SizedBox(height: 60), ], ), ), diff --git a/lib/ui/bookmarks/bookmark_category_recipes_screen.dart b/lib/ui/bookmarks/bookmark_category_recipes_screen.dart new file mode 100644 index 0000000..ceff159 --- /dev/null +++ b/lib/ui/bookmarks/bookmark_category_recipes_screen.dart @@ -0,0 +1,533 @@ +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'; + +/// Screen displaying recipes in a specific bookmark category. +class BookmarkCategoryRecipesScreen extends StatefulWidget { + final BookmarkCategory category; + + const BookmarkCategoryRecipesScreen({ + super.key, + required this.category, + }); + + @override + State createState() => _BookmarkCategoryRecipesScreenState(); +} + +class _BookmarkCategoryRecipesScreenState extends State { + List _recipes = []; + bool _isLoading = false; + String? _errorMessage; + RecipeService? _recipeService; + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _initializeService(); + _loadRecipes(); + _searchController.addListener(() { + setState(() { + _searchQuery = _searchController.text.toLowerCase(); + }); + }); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + 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 _loadRecipes() async { + if (_recipeService == null) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final recipes = await _recipeService!.getRecipesByCategory(widget.category.id); + + if (mounted) { + setState(() { + _recipes = recipes; + _isLoading = false; + }); + } + } catch (e) { + Logger.error('Failed to load recipes', e); + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'Failed to load recipes: $e'; + }); + } + } + } + + Color _parseColor(String hexColor) { + try { + return Color(int.parse(hexColor.replaceAll('#', '0xFF'))); + } catch (e) { + return Colors.blue; + } + } + + List _getFilteredRecipes() { + 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 recipes when returning from recipe view + _loadRecipes(); + }); + } + + 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 categoryColor = widget.category.color != null + ? _parseColor(widget.category.color!) + : Theme.of(context).primaryColor; + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bookmarks', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey.shade600, + ), + ), + Text( + widget.category.name, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + elevation: 0, + ), + body: RefreshIndicator( + onRefresh: _loadRecipes, + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? 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: _loadRecipes, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + ), + ) + : _getFilteredRecipes().isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.restaurant_menu, + size: 100, + color: Colors.grey.shade300, + ), + const SizedBox(height: 24), + Text( + _searchQuery.isNotEmpty + ? 'No recipes found' + : 'No recipes in this bookmark', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + _searchQuery.isNotEmpty + ? 'Try a different search term' + : 'Bookmark some recipes to see them here', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + : CustomScrollView( + slivers: [ + // Search bar + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search recipes...', + 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), + ), + ), + ), + ), + // Recipes list + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final recipe = _getFilteredRecipes()[index]; + return _BookmarkRecipeItem( + recipe: recipe, + onTap: () => _viewRecipe(recipe), + onPhotoTap: (index) => _openPhotoGallery( + recipe.imageUrls, + index, + ), + ); + }, + childCount: _getFilteredRecipes().length, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 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 Card( + margin: const EdgeInsets.only(bottom: 12), + 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( + 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, + ), + ], + ), + ), + ), + ); + } +} + diff --git a/lib/ui/bookmarks/bookmarks_screen.dart b/lib/ui/bookmarks/bookmarks_screen.dart index ad91f01..fa674b2 100644 --- a/lib/ui/bookmarks/bookmarks_screen.dart +++ b/lib/ui/bookmarks/bookmarks_screen.dart @@ -7,6 +7,7 @@ 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 { @@ -174,17 +175,78 @@ class _BookmarksScreenState extends State { }); } - void _openPhotoGallery(List imageUrls, int initialIndex) { - if (imageUrls.isEmpty) return; + void _openCategory(BookmarkCategory category) { Navigator.push( context, MaterialPageRoute( - builder: (context) => PhotoGalleryScreen( - imageUrls: imageUrls, - initialIndex: initialIndex, + 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 @@ -407,8 +469,8 @@ class _BookmarksScreenState extends State { categoryColor: category.color != null ? _parseColor(category.color!) : Theme.of(context).primaryColor, - onRecipeTap: _viewRecipe, - onPhotoTap: _openPhotoGallery, + onTap: () => _openCategory(category), + onDelete: () => _deleteCategory(category), ); }, childCount: filteredCategories.length, @@ -423,28 +485,21 @@ class _BookmarksScreenState extends State { } /// Modern category card widget -class _CategoryCard extends StatefulWidget { +class _CategoryCard extends StatelessWidget { final BookmarkCategory category; final List recipes; final Color categoryColor; - final Function(RecipeModel) onRecipeTap; - final Function(List, int) onPhotoTap; + final VoidCallback onTap; + final VoidCallback onDelete; const _CategoryCard({ required this.category, required this.recipes, required this.categoryColor, - required this.onRecipeTap, - required this.onPhotoTap, + required this.onTap, + required this.onDelete, }); - @override - State<_CategoryCard> createState() => _CategoryCardState(); -} - -class _CategoryCardState extends State<_CategoryCard> { - bool _isExpanded = false; - @override Widget build(BuildContext context) { return Card( @@ -453,116 +508,81 @@ class _CategoryCardState extends State<_CategoryCard> { 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: 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, ), ), - child: Icon( - Icons.bookmark, - color: widget.categoryColor, - size: 24, - ), - ), - const SizedBox(width: 16), - // Category info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 4), + Row( children: [ + Icon( + Icons.restaurant_menu, + size: 16, + color: Colors.grey.shade600, + ), + const SizedBox(width: 4), Text( - widget.category.name, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, + '${recipes.length} recipe${recipes.length != 1 ? 's' : ''}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey.shade600, ), ), - 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, - ), - ], + ], + ), ), - ), + // 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, + ), + ], ), - // 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(), - ), - ), - ], + ), ), ); }