better tag search

master
gitea 2 months ago
parent b568d42215
commit 7269a56b38

@ -583,39 +583,20 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
final tagController = TextEditingController();
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>(
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<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.
class _VideoThumbnailPreview extends StatefulWidget {

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

@ -39,6 +39,7 @@ class _RecipesScreenState extends State<RecipesScreen> with WidgetsBindingObserv
final TextEditingController _searchController = TextEditingController();
bool _isSearching = false;
DateTime? _lastRefreshTime;
Set<String> _selectedTags = {}; // Currently selected tags for filtering
@override
void initState() {
@ -108,20 +109,82 @@ class _RecipesScreenState extends State<RecipesScreen> 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<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
void didChangeDependencies() {
super.didChangeDependencies();
@ -362,6 +425,7 @@ class _RecipesScreenState extends State<RecipesScreen> 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<RecipesScreen> 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<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() {
final sessionService = ServiceLocator.instance.sessionService;
final isLoggedIn = sessionService?.isLoggedIn ?? false;

@ -203,7 +203,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
// 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<RelayManagementScreen> {
_originalUseNip05RelaysAutomatically = false;
_originalIsDarkMode = false;
_hasUnsavedChanges = false;
});
});
}
}
}
@ -338,9 +338,9 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
}
} 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<RelayManagementScreen> {
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _editMediaServer(server),
@ -695,34 +695,34 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
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<RelayManagementScreen> {
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<RelayManagementScreen> {
),
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,
),
),
),
],
),

@ -19,29 +19,72 @@ class PrimaryAppBar extends StatefulWidget implements PreferredSizeWidget {
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _PrimaryAppBarState extends State<PrimaryAppBar> {
class _PrimaryAppBarState extends State<PrimaryAppBar> 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<PrimaryAppBar> {
_avatarBytes = null;
_isLoadingAvatar = false;
_lastProfilePictureUrl = null;
_lastUserId = null;
});
}
return;
@ -66,11 +110,15 @@ class _PrimaryAppBarState extends State<PrimaryAppBar> {
_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

@ -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)

Loading…
Cancel
Save

Powered by TurnKey Linux.