From a477271d5f2ffd52560c36d85224f86840d709e1 Mon Sep 17 00:00:00 2001 From: gitea Date: Fri, 14 Nov 2025 20:03:14 +0100 Subject: [PATCH] better tag search and add --- lib/ui/add_recipe/add_recipe_screen.dart | 176 +++++++++++++++-------- lib/ui/recipes/recipes_screen.dart | 124 ++++++++++++---- 2 files changed, 213 insertions(+), 87 deletions(-) diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index 3c91a1e..37369c6 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -553,9 +553,12 @@ class _AddRecipeScreenState extends State { Future _removeTag(String tag) async { if (_recipeService == null || _recipe == null) return; + final currentRecipe = _currentRecipe ?? widget.recipe; + if (currentRecipe == null) return; + try { - final updatedTags = List.from(_recipe!.tags)..remove(tag); - final updatedRecipe = _recipe!.copyWith(tags: updatedTags); + final updatedTags = List.from(currentRecipe.tags)..remove(tag); + final updatedRecipe = currentRecipe.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { @@ -591,7 +594,7 @@ class _AddRecipeScreenState extends State { } final existingTags = allTags.toList()..sort(); - final result = await showDialog( + final result = await showDialog>( context: context, builder: (context) => _AddTagDialog( tagController: tagController, @@ -604,8 +607,8 @@ class _AddRecipeScreenState extends State { tagController.dispose(); }); - if (mounted && result != null && result.trim().isNotEmpty) { - await _addTag(result.trim()); + if (mounted && result != null && result.isNotEmpty) { + await _addTags(result); } } catch (e) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -614,17 +617,22 @@ class _AddRecipeScreenState extends State { } } - Future _addTag(String tag) async { + Future _addTags(List tags) async { if (_recipeService == null || _recipe == null) return; - final trimmedTag = tag.trim(); - if (trimmedTag.isEmpty) return; + final currentRecipe = _currentRecipe ?? widget.recipe; + if (currentRecipe == null) return; + + final trimmedTags = tags.map((tag) => tag.trim()).where((tag) => tag.isNotEmpty).toList(); + if (trimmedTags.isEmpty) return; - if (_recipe!.tags.contains(trimmedTag)) { + // Filter out tags that already exist + final newTags = trimmedTags.where((tag) => !currentRecipe.tags.contains(tag)).toList(); + if (newTags.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Tag already exists'), + content: Text('All tags already exist'), duration: Duration(seconds: 1), ), ); @@ -633,8 +641,8 @@ class _AddRecipeScreenState extends State { } try { - final updatedTags = List.from(_recipe!.tags)..add(trimmedTag); - final updatedRecipe = _recipe!.copyWith(tags: updatedTags); + final updatedTags = List.from(currentRecipe.tags)..addAll(newTags); + final updatedRecipe = currentRecipe.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { @@ -643,16 +651,16 @@ class _AddRecipeScreenState extends State { _tagsController.text = updatedTags.join(', '); }); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Tag added'), - duration: Duration(seconds: 1), + SnackBar( + content: Text('${newTags.length} tag${newTags.length > 1 ? 's' : ''} added'), + duration: const Duration(seconds: 1), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error adding tag: $e')), + SnackBar(content: Text('Error adding tags: $e')), ); } } @@ -1781,7 +1789,7 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS late AnimationController _animationController; late Animation _animation; bool _isUserScrolling = false; - DateTime? _lastUserScrollTime; + final Set _selectedTags = {}; @override void initState() { @@ -1846,16 +1854,47 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.jumpTo(0); - _isUserScrolling = false; - _lastUserScrollTime = null; - _startAutoScroll(); + // Don't restart auto-scroll if user has interacted + if (!_isUserScrolling) { + _startAutoScroll(); + } } }); }); } void _selectTag(String tag) { - Navigator.pop(context, tag); + // Stop auto-scroll permanently when user selects a tag + _isUserScrolling = true; + _animationController.stop(); + + setState(() { + if (_selectedTags.contains(tag)) { + _selectedTags.remove(tag); + } else { + _selectedTags.add(tag); + } + }); + } + + List _getTagsToAdd() { + final tags = []; + + // Add selected tags from the list + tags.addAll(_selectedTags); + + // Parse comma-separated tags from text field + final textTags = widget.tagController.text + .split(',') + .map((tag) => tag.trim()) + .where((tag) => tag.isNotEmpty) + .toList(); + tags.addAll(textTags); + + // Remove duplicates and tags that already exist in the recipe + final uniqueTags = tags.toSet().where((tag) => !widget.currentTags.contains(tag)).toList(); + + return uniqueTags; } @override @@ -1877,16 +1916,11 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS controller: widget.tagController, autofocus: true, decoration: InputDecoration( - hintText: 'Enter tag name or select from existing', + hintText: 'Enter tags (comma-separated) or select from existing', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.search), ), textCapitalization: TextCapitalization.none, - onSubmitted: (value) { - if (value.trim().isNotEmpty) { - Navigator.pop(context, value.trim()); - } - }, ), const SizedBox(height: 16), // Existing tags scrollable list with fade hint @@ -1902,26 +1936,11 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS height: 50, child: NotificationListener( onNotification: (notification) { - // Detect when user manually scrolls - if (notification is ScrollStartNotification && notification.dragDetails != null) { - _isUserScrolling = true; - _lastUserScrollTime = DateTime.now(); - _animationController.stop(); - } else if (notification is ScrollEndNotification) { - // Resume auto-scroll after user stops scrolling for 3 seconds - _lastUserScrollTime = DateTime.now(); - Future.delayed(const Duration(seconds: 3), () { - if (mounted && _lastUserScrollTime != null) { - final timeSinceLastScroll = DateTime.now().difference(_lastUserScrollTime!); - if (timeSinceLastScroll.inSeconds >= 3) { - setState(() { - _isUserScrolling = false; - }); - _startAutoScroll(); - } - } - }); - } + // Detect when user manually scrolls - stop auto-scroll permanently + if (notification is ScrollStartNotification && notification.dragDetails != null) { + _isUserScrolling = true; + _animationController.stop(); + } return false; }, child: ListView.builder( @@ -1931,11 +1950,34 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS itemBuilder: (context, index) { final tag = _filteredTags[index]; final isAlreadyAdded = widget.currentTags.contains(tag); + final isSelected = _selectedTags.contains(tag); return Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( - label: Text(tag), - selected: isAlreadyAdded, + label: Text( + tag, + style: TextStyle( + color: isSelected + ? Colors.white + : (isAlreadyAdded + ? (isDark ? Colors.white : Colors.black87) + : (isDark ? Colors.white : Colors.black87)), + fontWeight: isSelected + ? FontWeight.w700 + : (isAlreadyAdded ? FontWeight.w500 : FontWeight.normal), + fontSize: isSelected ? 14 : null, + shadows: isSelected + ? [ + Shadow( + color: Colors.black.withValues(alpha: 0.5), + offset: const Offset(0, 1), + blurRadius: 2, + ), + ] + : null, + ), + ), + selected: isSelected, onSelected: isAlreadyAdded ? null : (selected) { @@ -1946,12 +1988,24 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], - disabledColor: Colors.grey[400], - labelStyle: TextStyle( - color: isAlreadyAdded - ? Colors.white - : (isDark ? Colors.white : Colors.black87), - ), + disabledColor: isDark + ? Colors.grey[600] + : Colors.grey[100], + side: isSelected + ? BorderSide( + color: Colors.white.withValues(alpha: 0.5), + width: 2.5, + ) + : (isAlreadyAdded + ? BorderSide( + color: (isDark ? Colors.grey[500]! : Colors.grey[400]!), + width: 1, + ) + : null), + elevation: isSelected ? 3 : 0, + padding: isSelected + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 8) + : null, ), ); }, @@ -1975,12 +2029,16 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS ), FilledButton( onPressed: () { - final text = widget.tagController.text.trim(); - if (text.isNotEmpty) { - Navigator.pop(context, text); + final tagsToAdd = _getTagsToAdd(); + if (tagsToAdd.isNotEmpty) { + Navigator.pop(context, tagsToAdd); + } else { + Navigator.pop(context); } }, - child: const Text('Add'), + child: Text(_selectedTags.isEmpty && widget.tagController.text.trim().isEmpty + ? 'Add' + : 'Add (${_getTagsToAdd().length})'), ), ], ); diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 1e42642..7ef725c 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -28,7 +28,7 @@ class RecipesScreen extends StatefulWidget { } } -class _RecipesScreenState extends State with WidgetsBindingObserver { +class _RecipesScreenState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin { List _recipes = []; List _filteredRecipes = []; bool _isLoading = false; @@ -40,6 +40,12 @@ class _RecipesScreenState extends State with WidgetsBindingObserv bool _isSearching = false; DateTime? _lastRefreshTime; 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() { @@ -49,15 +55,53 @@ class _RecipesScreenState extends State with WidgetsBindingObserv _checkLoginState(); _loadPreferences(); _searchController.addListener(_onSearchChanged); + + // 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() { WidgetsBinding.instance.removeObserver(this); _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 didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); @@ -169,6 +213,10 @@ class _RecipesScreenState extends State with WidgetsBindingObserv /// 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); @@ -491,34 +539,54 @@ class _RecipesScreenState extends State with WidgetsBindingObserv ), ), ) - : ListView.builder( - 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), - selected: isSelected, - onSelected: (selected) { - _selectTag(tag); - }, - selectedColor: theme.primaryColor, - checkmarkColor: Colors.white, - backgroundColor: isDark - ? Colors.grey[800] - : Colors.grey[200], - labelStyle: TextStyle( - color: isSelected - ? Colors.white - : (isDark ? Colors.white : Colors.black87), - ), - ), - ); + : 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, + ), + ); + }, + ), ), ), );