diff --git a/lib/ui/bookmarks/bookmarks_screen.dart b/lib/ui/bookmarks/bookmarks_screen.dart index 384a9ea..ad91f01 100644 --- a/lib/ui/bookmarks/bookmarks_screen.dart +++ b/lib/ui/bookmarks/bookmarks_screen.dart @@ -23,12 +23,25 @@ class _BookmarksScreenState extends State { 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 @@ -123,6 +136,29 @@ class _BookmarksScreenState extends State { } } + 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, @@ -158,8 +194,36 @@ class _BookmarksScreenState extends State { if (!isLoggedIn) { return Scaffold( appBar: AppBar(title: const Text('Bookmarks')), - body: const Center( - child: Text('Please log in to view 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, + ), + ], + ), + ), ), ); } @@ -175,16 +239,41 @@ class _BookmarksScreenState extends State { return Scaffold( appBar: AppBar(title: const Text('Bookmarks')), body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadBookmarks, - child: const Text('Retry'), - ), - ], + 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), + ), + ), + ], + ), ), ), ); @@ -193,68 +282,287 @@ class _BookmarksScreenState extends State { if (_categories.isEmpty) { return Scaffold( appBar: AppBar(title: const Text('Bookmarks')), - body: const Center( - child: Text( - 'No bookmark categories yet.\nBookmark a recipe to get started!', - textAlign: TextAlign.center, + 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'), - actions: [ - ], + elevation: 0, ), body: RefreshIndicator( onRefresh: _loadBookmarks, - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _categories.length, - itemBuilder: (context, index) { - final category = _categories[index]; - final recipes = _recipesByCategory[category.id] ?? []; - - return Card( - margin: const EdgeInsets.only(bottom: 16), - child: ExpansionTile( - leading: category.color != null - ? Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: _parseColor(category.color!), - shape: BoxShape.circle, + 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 Icon(Icons.bookmark), - title: Text( - category.name, - style: const TextStyle(fontWeight: FontWeight.bold), + 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, + ), ), - subtitle: Text('${recipes.length} recipe(s)'), - children: recipes.isEmpty - ? [ - const ListTile( - title: Text('No recipes in this category'), - ) - ] - : recipes.map((recipe) { + ), + ], + ), + ), + ); + } +} + +/// 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: () => _viewRecipe(recipe), - onPhotoTap: (index) => _openPhotoGallery( + onTap: () => widget.onRecipeTap(recipe), + onPhotoTap: (index) => widget.onPhotoTap( recipe.imageUrls, index, ), ); }).toList(), - ), - ); - }, - ), + ), + ), + ], ), ); } @@ -353,58 +661,130 @@ class _BookmarkRecipeItem extends StatelessWidget { ); } + 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 ListTile( - leading: recipe.imageUrls.isNotEmpty - ? GestureDetector( - onTap: () => onPhotoTap(0), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: 60, - height: 60, - child: _buildRecipeImage(recipe.imageUrls.first), - ), - ), - ) - : Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(8), + 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, + ), + ], + ], + ), + ], ), - child: const Icon(Icons.restaurant_menu), ), - title: Text( - recipe.title, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (recipe.description != null && recipe.description!.isNotEmpty) - Text( - recipe.description!, - maxLines: 2, - overflow: TextOverflow.ellipsis, + // Chevron icon + Icon( + Icons.chevron_right, + color: Colors.grey.shade400, ), - const SizedBox(height: 4), - Row( - children: [ - const Icon(Icons.star, size: 16, color: Colors.amber), - const SizedBox(width: 4), - Text(recipe.rating.toString()), - if (recipe.isFavourite) ...[ - const SizedBox(width: 12), - const Icon(Icons.favorite, size: 16, color: Colors.red), - ], - ], - ), - ], + ], + ), ), - onTap: onTap, ); } } diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 126b2c7..fd71b1b 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -1173,7 +1173,10 @@ class _RecipeCard extends StatelessWidget { Widget _buildRecipeImage(String imageUrl) { final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { - return Image.network(imageUrl, fit: BoxFit.cover); + return _FadeInImageWidget( + imageUrl: imageUrl, + isNetwork: true, + ); } // Try to extract asset ID from Immich URL (format: .../api/assets/{id}/original) @@ -1205,41 +1208,16 @@ class _RecipeCard extends StatelessWidget { ); } - return Image.memory( - snapshot.data!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, + return _FadeInImageWidget( + imageBytes: snapshot.data!, ); }, ); } - 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.image_not_supported, size: 32, color: Colors.grey), - ); - }, - 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, - ), - ), - ); - }, + return _FadeInImageWidget( + imageUrl: imageUrl, + isNetwork: true, ); } @@ -1254,6 +1232,124 @@ class _RecipeCard extends StatelessWidget { } } +/// Widget that displays an image with a smooth fade-in animation. +class _FadeInImageWidget extends StatefulWidget { + final String? imageUrl; + final Uint8List? imageBytes; + final bool isNetwork; + + const _FadeInImageWidget({ + this.imageUrl, + this.imageBytes, + this.isNetwork = false, + }) : assert(imageUrl != null || imageBytes != null, 'Either imageUrl or imageBytes must be provided'); + + @override + State<_FadeInImageWidget> createState() => _FadeInImageWidgetState(); +} + +class _FadeInImageWidgetState extends State<_FadeInImageWidget> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + bool _imageLoaded = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + // Start with opacity 0 + _controller.value = 0.0; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onImageLoaded() { + if (!_imageLoaded && mounted) { + _imageLoaded = true; + _controller.forward(); + } + } + + @override + Widget build(BuildContext context) { + Widget imageWidget; + + if (widget.imageBytes != null) { + // For memory images, use frameBuilder to detect when image is ready + imageWidget = Image.memory( + widget.imageBytes!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if ((wasSynchronouslyLoaded || frame != null) && !_imageLoaded) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onImageLoaded()); + } + return child; + }, + ); + } else if (widget.imageUrl != null) { + // For network images, use frameBuilder to detect when image is ready + imageWidget = Image.network( + widget.imageUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.image_not_supported, size: 32, color: Colors.grey), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + // Image is loaded, return child and let frameBuilder handle fade-in + return child; + } + return Container( + color: Colors.grey.shade200, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if ((wasSynchronouslyLoaded || frame != null) && !_imageLoaded) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onImageLoaded()); + } + return child; + }, + ); + } else { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.image_not_supported, size: 32, color: Colors.grey), + ); + } + + return FadeTransition( + opacity: _fadeAnimation, + child: imageWidget, + ); + } +} /// Widget that displays a video thumbnail preview with a few seconds of playback. class _VideoThumbnailPreview extends StatefulWidget {