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 {

@ -254,7 +254,7 @@ class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
// Home // Home
_buildNavItem( _buildNavItem(
icon: Icons.home, icon: Icons.home,
label: 'Home', label: 'Home',
index: 0, index: 0,
onTap: () => _onItemTapped(0), onTap: () => _onItemTapped(0),
), ),

@ -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
final titleMatch = recipe.title.toLowerCase().contains(query); if (_selectedTags.isNotEmpty) {
final descriptionMatch = recipe.description?.toLowerCase().contains(query) ?? false; if (!_selectedTags.every((tag) => recipe.tags.contains(tag))) {
final tagsMatch = recipe.tags.any((tag) => tag.toLowerCase().contains(query)); return false;
return titleMatch || descriptionMatch || tagsMatch; }
}
// 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(); }).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;

@ -203,7 +203,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
// Database might be closed (e.g., during tests) - handle gracefully // Database might be closed (e.g., during tests) - handle gracefully
Logger.error('Failed to load settings: $e', e); Logger.error('Failed to load settings: $e', e);
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoadingSetting = false; _isLoadingSetting = false;
// Set defaults if loading failed // Set defaults if loading failed
_useNip05RelaysAutomatically = false; _useNip05RelaysAutomatically = false;
@ -211,7 +211,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
_originalUseNip05RelaysAutomatically = false; _originalUseNip05RelaysAutomatically = false;
_originalIsDarkMode = false; _originalIsDarkMode = false;
_hasUnsavedChanges = false; _hasUnsavedChanges = false;
}); });
} }
} }
} }
@ -338,9 +338,9 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
} }
} catch (e) { } catch (e) {
Logger.error('Failed to save settings: $e'); Logger.error('Failed to save settings: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to save settings: $e'), content: Text('Failed to save settings: $e'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
@ -631,7 +631,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () => _editMediaServer(server), onPressed: () => _editMediaServer(server),
@ -695,34 +695,34 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
subtitle: Text('${widget.controller.relays.length} relay(s) configured'), subtitle: Text('${widget.controller.relays.length} relay(s) configured'),
initiallyExpanded: false, initiallyExpanded: false,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Test All and Toggle All buttons
Row(
children: [ children: [
// Test All and Toggle All buttons Expanded(
Row( child: ElevatedButton.icon(
children: [ onPressed: widget.controller.isCheckingHealth
Expanded( ? null
child: ElevatedButton.icon( : widget.controller.checkRelayHealth,
onPressed: widget.controller.isCheckingHealth icon: widget.controller.isCheckingHealth
? null ? const SizedBox(
: widget.controller.checkRelayHealth, width: 16,
icon: widget.controller.isCheckingHealth height: 16,
? const SizedBox( child: CircularProgressIndicator(strokeWidth: 2),
width: 16, )
height: 16, : const Icon(Icons.network_check),
child: CircularProgressIndicator(strokeWidth: 2), label: const Text('Test All'),
) ),
: const Icon(Icons.network_check), ),
label: const Text('Test All'), const SizedBox(width: 8),
), Expanded(
), child: ElevatedButton.icon(
const SizedBox(width: 8), onPressed: widget.controller.relays.isEmpty
Expanded( ? null
child: ElevatedButton.icon(
onPressed: widget.controller.relays.isEmpty
? null
: () async { : () async {
final hasEnabled = widget.controller.relays.any((r) => r.isEnabled); final hasEnabled = widget.controller.relays.any((r) => r.isEnabled);
if (hasEnabled) { if (hasEnabled) {
@ -731,43 +731,43 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
await widget.controller.turnAllOn(); await widget.controller.turnAllOn();
} }
}, },
icon: const Icon(Icons.power_settings_new), icon: const Icon(Icons.power_settings_new),
label: Text( label: Text(
widget.controller.relays.isNotEmpty && widget.controller.relays.isNotEmpty &&
widget.controller.relays.any((r) => r.isEnabled) widget.controller.relays.any((r) => r.isEnabled)
? 'Turn All Off' ? 'Turn All Off'
: 'Turn All On', : 'Turn All On',
),
),
), ),
], ),
), ),
const SizedBox(height: 16), ],
// Add relay input ),
Row( const SizedBox(height: 16),
children: [ // Add relay input
Expanded( Row(
child: TextField( children: [
controller: _urlController, Expanded(
decoration: InputDecoration( child: TextField(
labelText: 'Relay URL', controller: _urlController,
hintText: 'wss://relay.example.com', decoration: InputDecoration(
border: const OutlineInputBorder(), labelText: 'Relay URL',
), hintText: 'wss://relay.example.com',
keyboardType: TextInputType.url, border: const OutlineInputBorder(),
),
), ),
const SizedBox(width: 8), keyboardType: TextInputType.url,
ElevatedButton.icon( ),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () async { onPressed: () async {
final url = _urlController.text.trim(); final url = _urlController.text.trim();
if (url.isNotEmpty) { if (url.isNotEmpty) {
final success = await widget.controller.addRelay(url); final success = await widget.controller.addRelay(url);
if (mounted) { if (mounted) {
if (success) { if (success) {
_urlController.clear(); _urlController.clear();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Relay added and connected successfully'), content: Text('Relay added and connected successfully'),
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: Duration(seconds: 1), duration: Duration(seconds: 1),
@ -781,72 +781,72 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
), ),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );
} }
} }
} }
}, },
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Add'), label: const Text('Add'),
),
],
), ),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Relay list // Relay list
SizedBox( SizedBox(
height: 300, height: 300,
child: widget.controller.relays.isEmpty child: widget.controller.relays.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.cloud_off, Icons.cloud_off,
size: 48, size: 48,
color: Colors.grey.shade400, color: Colors.grey.shade400,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No relays configured', 'No relays configured',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey, color: Colors.grey,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Add a relay to get started', 'Add a relay to get started',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey, color: Colors.grey,
),
),
],
), ),
) ),
: ListView.builder( ],
),
)
: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
itemCount: widget.controller.relays.length, itemCount: widget.controller.relays.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final relay = widget.controller.relays[index]; final relay = widget.controller.relays[index];
return _RelayListItem( return _RelayListItem(
relay: relay, relay: relay,
onToggle: () async { onToggle: () async {
await widget.controller.toggleRelay(relay.url); await widget.controller.toggleRelay(relay.url);
}, },
onRemove: () { onRemove: () {
widget.controller.removeRelay(relay.url); widget.controller.removeRelay(relay.url);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Relay ${relay.url} removed'), content: Text('Relay ${relay.url} removed'),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
);
},
);
},
), ),
), );
], },
);
},
),
),
],
), ),
), ),
], ],
@ -884,21 +884,21 @@ class _RelayListItem extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row( child: Row(
children: [ children: [
// Status indicator // Status indicator
Container( Container(
width: 10, width: 10,
height: 10, height: 10,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: relay.isConnected && relay.isEnabled color: relay.isConnected && relay.isEnabled
? Colors.green ? Colors.green
: Colors.grey, : Colors.grey,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// URL and status text // URL and status text
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -911,17 +911,17 @@ class _RelayListItem extends StatelessWidget {
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1, maxLines: 1,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
relay.isConnected && relay.isEnabled relay.isConnected && relay.isEnabled
? 'Connected' ? 'Connected'
: 'Disabled', : 'Disabled',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: relay.isConnected && relay.isEnabled color: relay.isConnected && relay.isEnabled
? Colors.green ? Colors.green
: Colors.grey, : Colors.grey,
), ),
), ),
], ],
@ -940,30 +940,30 @@ class _RelayListItem extends StatelessWidget {
color: relay.isEnabled color: relay.isEnabled
? Colors.green ? Colors.green
: Colors.grey[600], : Colors.grey[600],
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Transform.scale( Transform.scale(
scale: 0.8, scale: 0.8,
child: Switch( child: Switch(
value: relay.isEnabled, value: relay.isEnabled,
onChanged: (_) => onToggle(), onChanged: (_) => onToggle(),
), ),
), ),
], ],
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
// Remove button // Remove button
IconButton( IconButton(
icon: const Icon(Icons.delete, size: 18), icon: const Icon(Icons.delete, size: 18),
color: Colors.red, color: Colors.red,
tooltip: 'Remove', tooltip: 'Remove',
onPressed: onRemove, onPressed: onRemove,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints( constraints: const BoxConstraints(
minWidth: 32, minWidth: 32,
minHeight: 32, minHeight: 32,
), ),
), ),
], ],
), ),

@ -19,29 +19,72 @@ 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) {
final currentUser = sessionService.currentUser; // Clear avatar if no session service
final profilePictureUrl = currentUser?.nostrProfile?.picture; if (mounted) {
if (profilePictureUrl != _lastProfilePictureUrl) { setState(() {
_lastProfilePictureUrl = profilePictureUrl; _avatarBytes = null;
_loadAvatar(); _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<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

@ -65,7 +65,7 @@ void main() async {
syncEngine: syncEngine, syncEngine: syncEngine,
); );
}); });
tearDown(() async { tearDown(() async {
// Wait for any pending async operations to complete before cleanup // Wait for any pending async operations to complete before cleanup
await Future.delayed(const Duration(milliseconds: 200)); 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 // Relay URLs should appear in the UI if controller has reloaded
if (controller.relays.length >= 2) { if (controller.relays.length >= 2) {
expect(find.textContaining('wss://relay1.example.com'), findsWidgets); expect(find.textContaining('wss://relay1.example.com'), findsWidgets);
expect(find.textContaining('wss://relay2.example.com'), findsWidgets); expect(find.textContaining('wss://relay2.example.com'), findsWidgets);
// Verify we have relay list items // Verify we have relay list items
final relayCards = find.byType(Card); final relayCards = find.byType(Card);
expect(relayCards, findsAtLeastNWidgets(controller.relays.length)); expect(relayCards, findsAtLeastNWidgets(controller.relays.length));
@ -295,7 +295,7 @@ void main() async {
// Verify relay is in list // Verify relay is in list
expect(find.text('wss://relay.example.com'), findsWidgets); expect(find.text('wss://relay.example.com'), findsWidgets);
expect(testController.relays.length, equals(1)); expect(testController.relays.length, equals(1));
// Find delete button within the ListView (relay delete button) // Find delete button within the ListView (relay delete button)
final listView = find.byType(ListView); final listView = find.byType(ListView);
expect(listView, findsOneWidget); expect(listView, findsOneWidget);
@ -433,7 +433,7 @@ void main() async {
await tester.tap(testAllButton); await tester.tap(testAllButton);
await tester.pump(); await tester.pump();
// Check for loading indicator in UI (may appear very briefly) // Check for loading indicator in UI (may appear very briefly)
// The loading indicator appears when isCheckingHealth is true // The loading indicator appears when isCheckingHealth is true
// Since health check is async, check multiple times quickly // 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)); 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) // 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 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) // The button works and the health check runs (even if we don't catch the loading state)

Loading…
Cancel
Save

Powered by TurnKey Linux.