From 91e10c89b9725dbe5557eb536a4160d11b719553 Mon Sep 17 00:00:00 2001 From: gitea Date: Wed, 19 Nov 2025 16:00:06 +0100 Subject: [PATCH] search improved in bookmarks --- .../bookmark_category_recipes_screen.dart | 324 +++++++++++++--- lib/ui/bookmarks/bookmarks_screen.dart | 350 +++++++++++++++--- 2 files changed, 577 insertions(+), 97 deletions(-) diff --git a/lib/ui/bookmarks/bookmark_category_recipes_screen.dart b/lib/ui/bookmarks/bookmark_category_recipes_screen.dart index ceff159..5ac2b01 100644 --- a/lib/ui/bookmarks/bookmark_category_recipes_screen.dart +++ b/lib/ui/bookmarks/bookmark_category_recipes_screen.dart @@ -21,13 +21,21 @@ class BookmarkCategoryRecipesScreen extends StatefulWidget { State createState() => _BookmarkCategoryRecipesScreenState(); } -class _BookmarkCategoryRecipesScreenState extends State { +class _BookmarkCategoryRecipesScreenState extends State with SingleTickerProviderStateMixin { List _recipes = []; bool _isLoading = false; String? _errorMessage; RecipeService? _recipeService; 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() { @@ -39,14 +47,52 @@ class _BookmarkCategoryRecipesScreenState extends State(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); + } + } + } + } + } + Future _initializeService() async { try { _recipeService = ServiceLocator.instance.recipeService; @@ -99,13 +145,78 @@ class _BookmarkCategoryRecipesScreenState extends State _getAllTags() { + final tags = {}; + 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 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 from recipes in this category + 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 _getFilteredRecipes() { - if (_searchQuery.isEmpty) { + final query = _searchQuery; + + if (query.isEmpty && _selectedTags.isEmpty) { return _recipes; } - return _recipes.where((recipe) => - recipe.title.toLowerCase().contains(_searchQuery) || - (recipe.description?.toLowerCase().contains(_searchQuery) ?? false)).toList(); + + 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) { @@ -136,6 +247,79 @@ class _BookmarkCategoryRecipesScreenState extends State( + 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, + ), + ); + }, + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final categoryColor = widget.category.color != null @@ -144,24 +328,88 @@ class _BookmarkCategoryRecipesScreenState extends State createState() => _BookmarksScreenState(); } -class _BookmarksScreenState extends State { +class _BookmarksScreenState extends State with SingleTickerProviderStateMixin { List _categories = []; Map> _recipesByCategory = {}; bool _isLoading = false; @@ -26,6 +27,14 @@ class _BookmarksScreenState extends State { 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() { @@ -37,14 +46,52 @@ class _BookmarksScreenState extends State { _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(); @@ -137,27 +184,111 @@ class _BookmarksScreenState extends State { } } + 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() { - if (_searchQuery.isEmpty) { + final query = _searchQuery; + + if (query.isEmpty && _selectedTags.isEmpty) { return _categories; } + return _categories.where((category) { final recipes = _recipesByCategory[category.id] ?? []; - return category.name.toLowerCase().contains(_searchQuery) || + + // Filter by search query + final matchesQuery = query.isEmpty || + category.name.toLowerCase().contains(query) || recipes.any((recipe) => - recipe.title.toLowerCase().contains(_searchQuery) || - (recipe.description?.toLowerCase().contains(_searchQuery) ?? false)); + 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] ?? []; - if (_searchQuery.isEmpty) { + final query = _searchQuery; + + if (query.isEmpty && _selectedTags.isEmpty) { return recipes; } - return recipes.where((recipe) => - recipe.title.toLowerCase().contains(_searchQuery) || - (recipe.description?.toLowerCase().contains(_searchQuery) ?? false)).toList(); + + 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) { @@ -189,6 +320,79 @@ class _BookmarksScreenState extends State { }); } + 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; @@ -383,45 +587,92 @@ class _BookmarksScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Bookmarks'), + 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: [ - // 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) + if (filteredCategories.isEmpty && (_searchQuery.isNotEmpty || _selectedTags.isNotEmpty)) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -437,7 +688,7 @@ class _BookmarksScreenState extends State { ), const SizedBox(height: 16), Text( - 'No results found', + 'No bookmarks found', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey.shade700, ), @@ -520,16 +771,27 @@ class _CategoryCard extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: categoryColor.withOpacity(0.2), + 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: categoryColor, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.95) + : categoryColor, size: 24, ), ),