From 7269a56b384f15f793bfa57a81275988c2cbae47 Mon Sep 17 00:00:00 2001 From: gitea Date: Fri, 14 Nov 2025 19:16:58 +0100 Subject: [PATCH] better tag search --- lib/ui/add_recipe/add_recipe_screen.dart | 270 ++++++++++++++-- .../navigation/main_navigation_scaffold.dart | 2 +- lib/ui/recipes/recipes_screen.dart | 132 +++++++- .../relay_management_screen.dart | 288 +++++++++--------- lib/ui/shared/primary_app_bar.dart | 64 +++- .../relay_management_screen_test.dart | 12 +- 6 files changed, 572 insertions(+), 196 deletions(-) diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index 00dad9a..3c91a1e 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -583,39 +583,20 @@ class _AddRecipeScreenState extends State { final tagController = TextEditingController(); try { + // Get all existing tags from all recipes + final allRecipes = await _recipeService?.getAllRecipes() ?? []; + final allTags = {}; + for (final recipe in allRecipes) { + allTags.addAll(recipe.tags); + } + final existingTags = allTags.toList()..sort(); + final result = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Add Tag'), - content: TextField( - controller: tagController, - 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'), - ), - ], + builder: (context) => _AddTagDialog( + tagController: tagController, + existingTags: existingTags, + currentTags: _recipe?.tags ?? [], ), ); @@ -1778,6 +1759,233 @@ class _AddRecipeScreenState extends State { } } +/// Dialog for adding tags with existing tags selection +class _AddTagDialog extends StatefulWidget { + final TextEditingController tagController; + final List existingTags; + final List 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 _filteredTags; + late ScrollController _scrollController; + late AnimationController _animationController; + late Animation _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(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( + 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. class _VideoThumbnailPreview extends StatefulWidget { diff --git a/lib/ui/navigation/main_navigation_scaffold.dart b/lib/ui/navigation/main_navigation_scaffold.dart index d4229aa..854d820 100644 --- a/lib/ui/navigation/main_navigation_scaffold.dart +++ b/lib/ui/navigation/main_navigation_scaffold.dart @@ -254,7 +254,7 @@ class MainNavigationScaffoldState extends State { // Home _buildNavItem( icon: Icons.home, - label: 'Home', + label: 'Home', index: 0, onTap: () => _onItemTapped(0), ), diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 3ca5a90..1e42642 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -39,6 +39,7 @@ class _RecipesScreenState extends State with WidgetsBindingObserv final TextEditingController _searchController = TextEditingController(); bool _isSearching = false; DateTime? _lastRefreshTime; + Set _selectedTags = {}; // Currently selected tags for filtering @override void initState() { @@ -108,20 +109,82 @@ class _RecipesScreenState extends State with WidgetsBindingObserv void _filterRecipes() { final query = _searchController.text.toLowerCase(); setState(() { - if (query.isEmpty) { + if (query.isEmpty && _selectedTags.isEmpty) { _filteredRecipes = List.from(_recipes); } else { _filteredRecipes = _recipes.where((recipe) { - // Search in title, description, and tags - 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)); - return titleMatch || descriptionMatch || tagsMatch; + // 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 descriptionMatch = recipe.description?.toLowerCase().contains(query) ?? false; + final tagsMatch = recipe.tags.any((tag) => tag.toLowerCase().contains(query)); + return titleMatch || descriptionMatch || tagsMatch; + } + + return true; }).toList(); } }); } + /// Gets all unique tags from all recipes + Set _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 + 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 void didChangeDependencies() { super.didChangeDependencies(); @@ -362,6 +425,7 @@ class _RecipesScreenState extends State with WidgetsBindingObserv ) : const Text('All Recipes'), elevation: 0, + bottom: _buildTagFilterBar(), actions: [ IconButton( icon: Icon(_isSearching ? Icons.close : Icons.search), @@ -370,10 +434,13 @@ class _RecipesScreenState extends State with WidgetsBindingObserv _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); + // Clear selected tags when closing search + _selectedTags.clear(); _filterRecipes(); } }); }, + tooltip: 'Search', ), IconButton( icon: const Icon(Icons.favorite), @@ -404,6 +471,59 @@ class _RecipesScreenState extends State 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() { final sessionService = ServiceLocator.instance.sessionService; final isLoggedIn = sessionService?.isLoggedIn ?? false; diff --git a/lib/ui/relay_management/relay_management_screen.dart b/lib/ui/relay_management/relay_management_screen.dart index 6afa059..0598ed0 100644 --- a/lib/ui/relay_management/relay_management_screen.dart +++ b/lib/ui/relay_management/relay_management_screen.dart @@ -203,7 +203,7 @@ class _RelayManagementScreenState extends State { // Database might be closed (e.g., during tests) - handle gracefully Logger.error('Failed to load settings: $e', e); if (mounted) { - setState(() { + setState(() { _isLoadingSetting = false; // Set defaults if loading failed _useNip05RelaysAutomatically = false; @@ -211,7 +211,7 @@ class _RelayManagementScreenState extends State { _originalUseNip05RelaysAutomatically = false; _originalIsDarkMode = false; _hasUnsavedChanges = false; - }); + }); } } } @@ -338,9 +338,9 @@ class _RelayManagementScreenState extends State { } } catch (e) { Logger.error('Failed to save settings: $e'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( content: Text('Failed to save settings: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 2), @@ -631,7 +631,7 @@ class _RelayManagementScreenState extends State { ), trailing: Row( mainAxisSize: MainAxisSize.min, - children: [ + children: [ IconButton( icon: const Icon(Icons.edit), onPressed: () => _editMediaServer(server), @@ -695,34 +695,34 @@ class _RelayManagementScreenState extends State { subtitle: Text('${widget.controller.relays.length} relay(s) configured'), initiallyExpanded: false, children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Test All and Toggle All buttons + Row( children: [ - // Test All and Toggle All buttons - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: widget.controller.isCheckingHealth - ? null - : widget.controller.checkRelayHealth, - icon: widget.controller.isCheckingHealth - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.network_check), - label: const Text('Test All'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - onPressed: widget.controller.relays.isEmpty - ? null + Expanded( + child: ElevatedButton.icon( + onPressed: widget.controller.isCheckingHealth + ? null + : widget.controller.checkRelayHealth, + icon: widget.controller.isCheckingHealth + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.network_check), + label: const Text('Test All'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: widget.controller.relays.isEmpty + ? null : () async { final hasEnabled = widget.controller.relays.any((r) => r.isEnabled); if (hasEnabled) { @@ -731,43 +731,43 @@ class _RelayManagementScreenState extends State { await widget.controller.turnAllOn(); } }, - icon: const Icon(Icons.power_settings_new), - label: Text( - widget.controller.relays.isNotEmpty && + icon: const Icon(Icons.power_settings_new), + label: Text( + widget.controller.relays.isNotEmpty && widget.controller.relays.any((r) => r.isEnabled) - ? 'Turn All Off' - : 'Turn All On', - ), - ), + ? 'Turn All Off' + : 'Turn All On', ), - ], + ), ), - const SizedBox(height: 16), - // Add relay input - Row( - children: [ - Expanded( - child: TextField( - controller: _urlController, - decoration: InputDecoration( - labelText: 'Relay URL', - hintText: 'wss://relay.example.com', - border: const OutlineInputBorder(), - ), - keyboardType: TextInputType.url, - ), + ], + ), + const SizedBox(height: 16), + // Add relay input + Row( + children: [ + Expanded( + child: TextField( + controller: _urlController, + decoration: InputDecoration( + labelText: 'Relay URL', + hintText: 'wss://relay.example.com', + border: const OutlineInputBorder(), ), - const SizedBox(width: 8), - ElevatedButton.icon( + keyboardType: TextInputType.url, + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( onPressed: () async { - final url = _urlController.text.trim(); - if (url.isNotEmpty) { + final url = _urlController.text.trim(); + if (url.isNotEmpty) { final success = await widget.controller.addRelay(url); if (mounted) { if (success) { - _urlController.clear(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + _urlController.clear(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( content: Text('Relay added and connected successfully'), backgroundColor: Colors.green, duration: Duration(seconds: 1), @@ -781,72 +781,72 @@ class _RelayManagementScreenState extends State { ), backgroundColor: Colors.orange, duration: const Duration(seconds: 3), - ), - ); + ), + ); } - } - } - }, - icon: const Icon(Icons.add), - label: const Text('Add'), - ), - ], + } + } + }, + icon: const Icon(Icons.add), + label: const Text('Add'), ), + ], + ), const SizedBox(height: 16), - // Relay list + // Relay list SizedBox( height: 300, - child: widget.controller.relays.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.cloud_off, + child: widget.controller.relays.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off, size: 48, - color: Colors.grey.shade400, - ), - const SizedBox(height: 16), - Text( - 'No relays configured', + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'No relays configured', style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - Text( - 'Add a relay to get started', + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + 'Add a relay to get started', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, - ), - ), - ], + color: Colors.grey, ), - ) - : ListView.builder( + ), + ], + ), + ) + : ListView.builder( shrinkWrap: true, - itemCount: widget.controller.relays.length, - itemBuilder: (context, index) { - final relay = widget.controller.relays[index]; - return _RelayListItem( - relay: relay, + itemCount: widget.controller.relays.length, + itemBuilder: (context, index) { + final relay = widget.controller.relays[index]; + return _RelayListItem( + relay: relay, onToggle: () async { await widget.controller.toggleRelay(relay.url); }, - onRemove: () { - widget.controller.removeRelay(relay.url); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Relay ${relay.url} removed'), - duration: const Duration(seconds: 2), - ), - ); - }, - ); - }, + onRemove: () { + widget.controller.removeRelay(relay.url); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Relay ${relay.url} removed'), + duration: const Duration(seconds: 2), ), - ), - ], + ); + }, + ); + }, + ), + ), + ], ), ), ], @@ -884,21 +884,21 @@ class _RelayListItem extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( - children: [ - // Status indicator - Container( + children: [ + // Status indicator + Container( width: 10, height: 10, - decoration: BoxDecoration( - shape: BoxShape.circle, + decoration: BoxDecoration( + shape: BoxShape.circle, color: relay.isConnected && relay.isEnabled - ? Colors.green - : Colors.grey, - ), - ), - const SizedBox(width: 8), + ? Colors.green + : Colors.grey, + ), + ), + const SizedBox(width: 8), // URL and status text - Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -911,17 +911,17 @@ class _RelayListItem extends StatelessWidget { ), overflow: TextOverflow.ellipsis, maxLines: 1, - ), + ), const SizedBox(height: 2), - Text( + Text( relay.isConnected && relay.isEnabled - ? 'Connected' - : 'Disabled', - style: TextStyle( + ? 'Connected' + : 'Disabled', + style: TextStyle( fontSize: 11, color: relay.isConnected && relay.isEnabled - ? Colors.green - : Colors.grey, + ? Colors.green + : Colors.grey, ), ), ], @@ -940,30 +940,30 @@ class _RelayListItem extends StatelessWidget { color: relay.isEnabled ? Colors.green : Colors.grey[600], - ), - ), - const SizedBox(width: 4), + ), + ), + const SizedBox(width: 4), Transform.scale( scale: 0.8, child: Switch( - value: relay.isEnabled, - onChanged: (_) => onToggle(), - ), + value: relay.isEnabled, + onChanged: (_) => onToggle(), + ), ), ], ), const SizedBox(width: 4), - // Remove button - IconButton( + // Remove button + IconButton( icon: const Icon(Icons.delete, size: 18), - color: Colors.red, - tooltip: 'Remove', - onPressed: onRemove, + color: Colors.red, + tooltip: 'Remove', + onPressed: onRemove, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, minHeight: 32, - ), + ), ), ], ), diff --git a/lib/ui/shared/primary_app_bar.dart b/lib/ui/shared/primary_app_bar.dart index ceb85ec..6781975 100644 --- a/lib/ui/shared/primary_app_bar.dart +++ b/lib/ui/shared/primary_app_bar.dart @@ -19,29 +19,72 @@ class PrimaryAppBar extends StatefulWidget implements PreferredSizeWidget { Size get preferredSize => const Size.fromHeight(kToolbarHeight); } -class _PrimaryAppBarState extends State { +class _PrimaryAppBarState extends State with WidgetsBindingObserver { Uint8List? _avatarBytes; bool _isLoadingAvatar = false; String? _lastProfilePictureUrl; // Track URL to avoid reloading + String? _lastUserId; // Track user ID to detect user changes @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _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 void didChangeDependencies() { super.didChangeDependencies(); - // Only reload if the profile picture URL has changed + _checkAndReloadAvatar(); + } + + void _checkAndReloadAvatar() { final sessionService = ServiceLocator.instance.sessionService; - if (sessionService != null && sessionService.isLoggedIn) { - final currentUser = sessionService.currentUser; - final profilePictureUrl = currentUser?.nostrProfile?.picture; - if (profilePictureUrl != _lastProfilePictureUrl) { - _lastProfilePictureUrl = profilePictureUrl; - _loadAvatar(); + 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 currentUserId = currentUser?.id; + 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) { + _lastProfilePictureUrl = profilePictureUrl; + _loadAvatar(); } } @@ -54,6 +97,7 @@ class _PrimaryAppBarState extends State { _avatarBytes = null; _isLoadingAvatar = false; _lastProfilePictureUrl = null; + _lastUserId = null; }); } return; @@ -66,11 +110,15 @@ class _PrimaryAppBarState extends State { _avatarBytes = null; _isLoadingAvatar = false; _lastProfilePictureUrl = null; + _lastUserId = null; }); } return; } + // Update last user ID + _lastUserId = currentUser.id; + final profilePictureUrl = currentUser.nostrProfile?.picture; if (profilePictureUrl == null || profilePictureUrl.isEmpty) { // Clear avatar if no profile picture diff --git a/test/ui/relay_management/relay_management_screen_test.dart b/test/ui/relay_management/relay_management_screen_test.dart index bece485..7c3a07c 100644 --- a/test/ui/relay_management/relay_management_screen_test.dart +++ b/test/ui/relay_management/relay_management_screen_test.dart @@ -65,7 +65,7 @@ void main() async { syncEngine: syncEngine, ); }); - + tearDown(() async { // Wait for any pending async operations to complete before cleanup await Future.delayed(const Duration(milliseconds: 200)); @@ -148,8 +148,8 @@ void main() async { // Relay URLs should appear in the UI if controller has reloaded if (controller.relays.length >= 2) { - expect(find.textContaining('wss://relay1.example.com'), findsWidgets); - expect(find.textContaining('wss://relay2.example.com'), findsWidgets); + expect(find.textContaining('wss://relay1.example.com'), findsWidgets); + expect(find.textContaining('wss://relay2.example.com'), findsWidgets); // Verify we have relay list items final relayCards = find.byType(Card); expect(relayCards, findsAtLeastNWidgets(controller.relays.length)); @@ -295,7 +295,7 @@ void main() async { // Verify relay is in list expect(find.text('wss://relay.example.com'), findsWidgets); expect(testController.relays.length, equals(1)); - + // Find delete button within the ListView (relay delete button) final listView = find.byType(ListView); expect(listView, findsOneWidget); @@ -433,7 +433,7 @@ void main() async { await tester.tap(testAllButton); await tester.pump(); - + // Check for loading indicator in UI (may appear very briefly) // The loading indicator appears when isCheckingHealth is true // Since health check is async, check multiple times quickly @@ -469,7 +469,7 @@ void main() async { await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); - + // Verify test completed without hanging (if we get here, test passed) // The main goal was to ensure the test doesn't hang, which it doesn't anymore // The button works and the health check runs (even if we don't catch the loading state)