better tag search

master
gitea 2 months ago
parent b568d42215
commit 7269a56b38

@ -583,39 +583,20 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
final tagController = TextEditingController(); final tagController = TextEditingController();
try { try {
// Get all existing tags from all recipes
final allRecipes = await _recipeService?.getAllRecipes() ?? [];
final allTags = <String>{};
for (final recipe in allRecipes) {
allTags.addAll(recipe.tags);
}
final existingTags = allTags.toList()..sort();
final result = await showDialog<String>( final result = await showDialog<String>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => _AddTagDialog(
title: const Text('Add Tag'), tagController: tagController,
content: TextField( existingTags: existingTags,
controller: tagController, currentTags: _recipe?.tags ?? [],
autofocus: true,
decoration: const InputDecoration(
hintText: 'Enter tag name',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.none,
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
Navigator.pop(context, value.trim());
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final text = tagController.text.trim();
if (text.isNotEmpty) {
Navigator.pop(context, text);
}
},
child: const Text('Add'),
),
],
), ),
); );
@ -1778,6 +1759,233 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
} }
} }
/// Dialog for adding tags with existing tags selection
class _AddTagDialog extends StatefulWidget {
final TextEditingController tagController;
final List<String> existingTags;
final List<String> currentTags;
const _AddTagDialog({
required this.tagController,
required this.existingTags,
required this.currentTags,
});
@override
State<_AddTagDialog> createState() => _AddTagDialogState();
}
class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderStateMixin {
late List<String> _filteredTags;
late ScrollController _scrollController;
late AnimationController _animationController;
late Animation<double> _animation;
bool _isUserScrolling = false;
DateTime? _lastUserScrollTime;
@override
void initState() {
super.initState();
_filteredTags = List.from(widget.existingTags);
widget.tagController.addListener(_filterTags);
_scrollController = ScrollController();
_animationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 8), // Slower animation
);
_animation = Tween<double>(begin: 0, end: 0.3).animate( // Only scroll 30% of the way
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_startAutoScroll();
_animation.addListener(_animateScroll);
}
@override
void dispose() {
widget.tagController.removeListener(_filterTags);
_animationController.dispose();
_scrollController.dispose();
super.dispose();
}
void _startAutoScroll() {
if (!_isUserScrolling && _filteredTags.length > 3) {
_animationController.repeat(reverse: true);
}
}
void _animateScroll() {
if (!_isUserScrolling && _scrollController.hasClients && _filteredTags.length > 3) {
final maxScroll = _scrollController.position.maxScrollExtent;
if (maxScroll > 0) {
// Only scroll a small amount (30% of max scroll) to suggest scrollability
final targetScroll = _animation.value * maxScroll;
if ((_scrollController.offset - targetScroll).abs() > 1) {
_scrollController.jumpTo(targetScroll);
}
}
}
}
void _filterTags() {
final query = widget.tagController.text.toLowerCase();
setState(() {
if (query.isEmpty) {
_filteredTags = List.from(widget.existingTags);
} else {
_filteredTags = widget.existingTags
.where((tag) => tag.toLowerCase().contains(query))
.toList();
}
// Reset scroll position when tags change
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
_isUserScrolling = false;
_lastUserScrollTime = null;
_startAutoScroll();
}
});
});
}
void _selectTag(String tag) {
Navigator.pop(context, tag);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return AlertDialog(
title: const Text('Add Tag'),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.95,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search field
TextField(
controller: widget.tagController,
autofocus: true,
decoration: InputDecoration(
hintText: 'Enter tag name 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
if (_filteredTags.isNotEmpty) ...[
Text(
'Existing Tags',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
SizedBox(
height: 50,
child: NotificationListener<ScrollNotification>(
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();
}
}
});
}
return false;
},
child: ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: _filteredTags.length,
itemBuilder: (context, index) {
final tag = _filteredTags[index];
final isAlreadyAdded = widget.currentTags.contains(tag);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(tag),
selected: isAlreadyAdded,
onSelected: isAlreadyAdded
? null
: (selected) {
_selectTag(tag);
},
selectedColor: theme.primaryColor,
checkmarkColor: Colors.white,
backgroundColor: isDark
? Colors.grey[800]
: Colors.grey[200],
disabledColor: Colors.grey[400],
labelStyle: TextStyle(
color: isAlreadyAdded
? Colors.white
: (isDark ? Colors.white : Colors.black87),
),
),
);
},
),
),
),
] else if (widget.tagController.text.isNotEmpty)
Text(
'No tags found',
style: theme.textTheme.bodySmall?.copyWith(
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final text = widget.tagController.text.trim();
if (text.isNotEmpty) {
Navigator.pop(context, text);
}
},
child: const Text('Add'),
),
],
);
}
}
/// Widget that displays a video thumbnail preview with a few seconds of playback. /// Widget that displays a video thumbnail preview with a few seconds of playback.
class _VideoThumbnailPreview extends StatefulWidget { class _VideoThumbnailPreview extends StatefulWidget {

@ -39,6 +39,7 @@ class _RecipesScreenState extends State<RecipesScreen> with WidgetsBindingObserv
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
bool _isSearching = false; bool _isSearching = false;
DateTime? _lastRefreshTime; DateTime? _lastRefreshTime;
Set<String> _selectedTags = {}; // Currently selected tags for filtering
@override @override
void initState() { void initState() {
@ -108,20 +109,82 @@ class _RecipesScreenState extends State<RecipesScreen> with WidgetsBindingObserv
void _filterRecipes() { void _filterRecipes() {
final query = _searchController.text.toLowerCase(); final query = _searchController.text.toLowerCase();
setState(() { setState(() {
if (query.isEmpty) { if (query.isEmpty && _selectedTags.isEmpty) {
_filteredRecipes = List.from(_recipes); _filteredRecipes = List.from(_recipes);
} else { } else {
_filteredRecipes = _recipes.where((recipe) { _filteredRecipes = _recipes.where((recipe) {
// Search in title, description, and tags // Tag filter - recipe must contain ALL selected tags
if (_selectedTags.isNotEmpty) {
if (!_selectedTags.every((tag) => recipe.tags.contains(tag))) {
return false;
}
}
// Search filter
if (query.isNotEmpty) {
final titleMatch = recipe.title.toLowerCase().contains(query); final titleMatch = recipe.title.toLowerCase().contains(query);
final descriptionMatch = recipe.description?.toLowerCase().contains(query) ?? false; final descriptionMatch = recipe.description?.toLowerCase().contains(query) ?? false;
final tagsMatch = recipe.tags.any((tag) => tag.toLowerCase().contains(query)); final tagsMatch = recipe.tags.any((tag) => tag.toLowerCase().contains(query));
return titleMatch || descriptionMatch || tagsMatch; return titleMatch || descriptionMatch || tagsMatch;
}
return true;
}).toList(); }).toList();
} }
}); });
} }
/// Gets all unique tags from all recipes
Set<String> _getAllTags() {
final tags = <String>{};
for (final recipe in _recipes) {
tags.addAll(recipe.tags);
}
return tags;
}
/// Gets tags from recipes that match the current search query
Set<String> _getFilteredTags() {
final query = _searchController.text.toLowerCase();
final tags = <String>{};
// 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
return _getAllTags();
}
return tags;
}
/// Handles tag selection for filtering
void _selectTag(String tag) {
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;
}
_filterRecipes();
});
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@ -362,6 +425,7 @@ class _RecipesScreenState extends State<RecipesScreen> with WidgetsBindingObserv
) )
: const Text('All Recipes'), : const Text('All Recipes'),
elevation: 0, elevation: 0,
bottom: _buildTagFilterBar(),
actions: [ actions: [
IconButton( IconButton(
icon: Icon(_isSearching ? Icons.close : Icons.search), icon: Icon(_isSearching ? Icons.close : Icons.search),
@ -370,10 +434,13 @@ class _RecipesScreenState extends State<RecipesScreen> with WidgetsBindingObserv
_isSearching = !_isSearching; _isSearching = !_isSearching;
if (!_isSearching) { if (!_isSearching) {
_searchController.clear(); _searchController.clear();
// Clear selected tags when closing search
_selectedTags.clear();
_filterRecipes(); _filterRecipes();
} }
}); });
}, },
tooltip: 'Search',
), ),
IconButton( IconButton(
icon: const Icon(Icons.favorite), icon: const Icon(Icons.favorite),
@ -404,6 +471,59 @@ class _RecipesScreenState extends State<RecipesScreen> with WidgetsBindingObserv
); );
} }
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],
),
),
)
: 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),
),
),
);
},
),
),
);
}
Widget _buildBody() { Widget _buildBody() {
final sessionService = ServiceLocator.instance.sessionService; final sessionService = ServiceLocator.instance.sessionService;
final isLoggedIn = sessionService?.isLoggedIn ?? false; final isLoggedIn = sessionService?.isLoggedIn ?? false;

@ -19,31 +19,74 @@ class PrimaryAppBar extends StatefulWidget implements PreferredSizeWidget {
Size get preferredSize => const Size.fromHeight(kToolbarHeight); Size get preferredSize => const Size.fromHeight(kToolbarHeight);
} }
class _PrimaryAppBarState extends State<PrimaryAppBar> { class _PrimaryAppBarState extends State<PrimaryAppBar> with WidgetsBindingObserver {
Uint8List? _avatarBytes; Uint8List? _avatarBytes;
bool _isLoadingAvatar = false; bool _isLoadingAvatar = false;
String? _lastProfilePictureUrl; // Track URL to avoid reloading String? _lastProfilePictureUrl; // Track URL to avoid reloading
String? _lastUserId; // Track user ID to detect user changes
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_loadAvatar(); _loadAvatar();
} }
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// Reload avatar when app resumes (in case user changed)
if (state == AppLifecycleState.resumed) {
_checkAndReloadAvatar();
}
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
// Only reload if the profile picture URL has changed _checkAndReloadAvatar();
}
void _checkAndReloadAvatar() {
final sessionService = ServiceLocator.instance.sessionService; final sessionService = ServiceLocator.instance.sessionService;
if (sessionService != null && sessionService.isLoggedIn) { if (sessionService == null) {
// Clear avatar if no session service
if (mounted) {
setState(() {
_avatarBytes = null;
_isLoadingAvatar = false;
_lastProfilePictureUrl = null;
_lastUserId = null;
});
}
return;
}
final currentUser = sessionService.currentUser; final currentUser = sessionService.currentUser;
final currentUserId = currentUser?.id;
final profilePictureUrl = currentUser?.nostrProfile?.picture; final profilePictureUrl = currentUser?.nostrProfile?.picture;
// If user ID changed, clear cache and reload
if (currentUserId != _lastUserId) {
_lastUserId = currentUserId;
_lastProfilePictureUrl = null; // Clear to force reload
_avatarBytes = null; // Clear cached avatar
_loadAvatar();
return;
}
// If profile picture URL changed, reload
if (profilePictureUrl != _lastProfilePictureUrl) { if (profilePictureUrl != _lastProfilePictureUrl) {
_lastProfilePictureUrl = profilePictureUrl; _lastProfilePictureUrl = profilePictureUrl;
_loadAvatar(); _loadAvatar();
} }
} }
}
Future<void> _loadAvatar() async { Future<void> _loadAvatar() async {
final sessionService = ServiceLocator.instance.sessionService; final sessionService = ServiceLocator.instance.sessionService;
@ -54,6 +97,7 @@ class _PrimaryAppBarState extends State<PrimaryAppBar> {
_avatarBytes = null; _avatarBytes = null;
_isLoadingAvatar = false; _isLoadingAvatar = false;
_lastProfilePictureUrl = null; _lastProfilePictureUrl = null;
_lastUserId = null;
}); });
} }
return; return;
@ -66,11 +110,15 @@ class _PrimaryAppBarState extends State<PrimaryAppBar> {
_avatarBytes = null; _avatarBytes = null;
_isLoadingAvatar = false; _isLoadingAvatar = false;
_lastProfilePictureUrl = null; _lastProfilePictureUrl = null;
_lastUserId = null;
}); });
} }
return; return;
} }
// Update last user ID
_lastUserId = currentUser.id;
final profilePictureUrl = currentUser.nostrProfile?.picture; final profilePictureUrl = currentUser.nostrProfile?.picture;
if (profilePictureUrl == null || profilePictureUrl.isEmpty) { if (profilePictureUrl == null || profilePictureUrl.isEmpty) {
// Clear avatar if no profile picture // Clear avatar if no profile picture

Loading…
Cancel
Save

Powered by TurnKey Linux.