bettered session and caching

master
gitea 2 months ago
parent 0b77956f78
commit fca1326f5e

@ -179,6 +179,17 @@ class AppInitializer {
firebaseService: firebaseService, firebaseService: firebaseService,
nostrService: nostrService, nostrService: nostrService,
); );
// Restore session if one was previously saved
try {
final restored = await sessionService.restoreSession();
if (restored) {
Logger.info('Previous session restored');
}
} catch (e) {
Logger.warning('Failed to restore session: $e');
}
Logger.info('Session service initialized'); Logger.info('Session service initialized');
// Initialize RecipeService // Initialize RecipeService

@ -419,5 +419,47 @@ class BlossomService implements MediaServiceInterface {
final cacheKey = '${sanitizedHash}_${isThumbnail ? 'thumb' : 'full'}'; final cacheKey = '${sanitizedHash}_${isThumbnail ? 'thumb' : 'full'}';
return path.join(_imageCacheDirectory!.path, cacheKey); return path.join(_imageCacheDirectory!.path, cacheKey);
} }
/// Clears the cache for a specific image URL or blob hash.
@override
Future<void> clearImageCache(String imageUrlOrBlobHash) async {
// Extract blob hash from URL if it's a full URL
String blobHash = imageUrlOrBlobHash;
if (imageUrlOrBlobHash.startsWith('http://') || imageUrlOrBlobHash.startsWith('https://')) {
// Extract hash from URL like: https://media.based21.com/08e301596aef7a232dcec85c4725f49ff88864f26a3c572f1fa6d1aedaecaf7e.webp
final uri = Uri.parse(imageUrlOrBlobHash);
final pathSegments = uri.pathSegments;
if (pathSegments.isNotEmpty) {
// Get the last segment (the hash with extension)
final lastSegment = pathSegments.last;
// Remove extension if present
blobHash = lastSegment.split('.').first;
}
}
// Clear both thumbnail and full image cache
final thumbCachePath = _getCacheFilePath(blobHash, true);
final fullCachePath = _getCacheFilePath(blobHash, false);
try {
if (thumbCachePath.isNotEmpty) {
final thumbFile = File(thumbCachePath);
if (await thumbFile.exists()) {
await thumbFile.delete();
Logger.debug('Cleared thumbnail cache: $blobHash');
}
}
if (fullCachePath.isNotEmpty) {
final fullFile = File(fullCachePath);
if (await fullFile.exists()) {
await fullFile.delete();
Logger.debug('Cleared full image cache: $blobHash');
}
}
} catch (e) {
Logger.warning('Failed to clear image cache for $blobHash: $e');
// Don't throw - cache clearing is best effort
}
}
} }

@ -787,4 +787,46 @@ class ImmichService implements MediaServiceInterface {
throw ImmichException('Failed to get server info: $e'); throw ImmichException('Failed to get server info: $e');
} }
} }
/// Clears the cache for a specific image URL or asset ID.
@override
Future<void> clearImageCache(String imageUrlOrAssetId) async {
// Extract asset ID from URL if it's a full URL
String assetId = imageUrlOrAssetId;
if (imageUrlOrAssetId.startsWith('http://') || imageUrlOrAssetId.startsWith('https://')) {
// Extract asset ID from Immich URL like: https://immich.example.com/api/assets/{id}/thumbnail
final uri = Uri.parse(imageUrlOrAssetId);
final pathSegments = uri.pathSegments;
if (pathSegments.length >= 3 && pathSegments[0] == 'api' && pathSegments[1] == 'assets') {
assetId = pathSegments[2];
} else {
// If we can't extract asset ID, try to use the URL as-is
assetId = imageUrlOrAssetId;
}
}
// Clear both thumbnail and full image cache
final thumbCachePath = _getCacheFilePath(assetId, true);
final fullCachePath = _getCacheFilePath(assetId, false);
try {
if (thumbCachePath.isNotEmpty) {
final thumbFile = File(thumbCachePath);
if (await thumbFile.exists()) {
await thumbFile.delete();
Logger.debug('Cleared thumbnail cache: $assetId');
}
}
if (fullCachePath.isNotEmpty) {
final fullFile = File(fullCachePath);
if (await fullFile.exists()) {
await fullFile.delete();
Logger.debug('Cleared full image cache: $assetId');
}
}
} catch (e) {
Logger.warning('Failed to clear image cache for $assetId: $e');
// Don't throw - cache clearing is best effort
}
}
} }

@ -15,5 +15,9 @@ abstract class MediaServiceInterface {
/// Gets the base URL. /// Gets the base URL.
String get baseUrl; String get baseUrl;
/// Clears the cache for a specific image URL or asset ID.
/// This is useful when images are removed from recipes to free up cache space.
Future<void> clearImageCache(String imageUrlOrAssetId);
} }

@ -274,5 +274,23 @@ class MultiMediaService implements MediaServiceInterface {
} }
return _servers.isNotEmpty ? _servers.first.baseUrl : ''; return _servers.isNotEmpty ? _servers.first.baseUrl : '';
} }
/// Clears the cache for a specific image URL or asset ID.
/// Tries all configured servers to clear the cache.
@override
Future<void> clearImageCache(String imageUrlOrAssetId) async {
// Try to clear cache from all configured servers
for (final config in _servers) {
try {
final service = _createServiceFromConfig(config);
if (service != null) {
await service.clearImageCache(imageUrlOrAssetId);
}
} catch (e) {
Logger.warning('Failed to clear cache from ${config.type} server ${config.baseUrl}: $e');
// Continue to next server
}
}
}
} }

@ -244,6 +244,23 @@ class RecipeService {
await _ensureInitializedOrReinitialize(); await _ensureInitializedOrReinitialize();
try { try {
// Try to get NostrKeyPair from SessionService if not already set
if (_nostrKeyPair == null) {
try {
final sessionService = ServiceLocator.instance.sessionService;
if (sessionService != null && sessionService.currentUser != null) {
final user = sessionService.currentUser!;
if (user.nostrPrivateKey != null) {
final keyPair = NostrKeyPair.fromNsec(user.nostrPrivateKey!);
_nostrKeyPair = keyPair;
Logger.info('Retrieved NostrKeyPair from SessionService for recipe creation');
}
}
} catch (e) {
Logger.warning('Failed to retrieve NostrKeyPair from SessionService: $e');
}
}
final now = DateTime.now().millisecondsSinceEpoch; final now = DateTime.now().millisecondsSinceEpoch;
final recipeWithTimestamps = recipe.copyWith( final recipeWithTimestamps = recipe.copyWith(
createdAt: now, createdAt: now,
@ -294,6 +311,26 @@ class RecipeService {
Future<RecipeModel> updateRecipe(RecipeModel recipe) async { Future<RecipeModel> updateRecipe(RecipeModel recipe) async {
_ensureInitialized(); _ensureInitialized();
try { try {
// Try to get NostrKeyPair from SessionService if not already set
if (_nostrKeyPair == null) {
try {
final sessionService = ServiceLocator.instance.sessionService;
if (sessionService != null && sessionService.currentUser != null) {
final user = sessionService.currentUser!;
if (user.nostrPrivateKey != null) {
final keyPair = NostrKeyPair.fromNsec(user.nostrPrivateKey!);
_nostrKeyPair = keyPair;
Logger.info('Retrieved NostrKeyPair from SessionService for recipe update');
}
}
} catch (e) {
Logger.warning('Failed to retrieve NostrKeyPair from SessionService: $e');
}
}
// Get the old recipe to compare image URLs
final oldRecipe = await getRecipe(recipe.id, includeDeleted: false);
final updatedRecipe = recipe.copyWith( final updatedRecipe = recipe.copyWith(
updatedAt: DateTime.now().millisecondsSinceEpoch, updatedAt: DateTime.now().millisecondsSinceEpoch,
); );
@ -311,14 +348,45 @@ class RecipeService {
Logger.info('Recipe updated: ${recipe.id}'); Logger.info('Recipe updated: ${recipe.id}');
// Clear cache for removed images
if (oldRecipe != null) {
final removedImages = oldRecipe.imageUrls
.where((url) => !updatedRecipe.imageUrls.contains(url))
.toList();
if (removedImages.isNotEmpty) {
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService != null) {
for (final imageUrl in removedImages) {
try {
await mediaService.clearImageCache(imageUrl);
Logger.debug('Cleared cache for removed image: $imageUrl');
} catch (e) {
Logger.warning('Failed to clear cache for image $imageUrl: $e');
// Don't fail the update if cache clearing fails
}
}
}
}
}
// Publish to Nostr if available // Publish to Nostr if available
if (_nostrService != null && _nostrKeyPair != null) { if (_nostrService != null && _nostrKeyPair != null) {
try { try {
Logger.info('Publishing recipe update ${recipe.id} to Nostr...');
await _publishRecipeToNostr(updatedRecipe); await _publishRecipeToNostr(updatedRecipe);
Logger.info('Recipe update ${recipe.id} published to Nostr successfully');
} catch (e) { } catch (e) {
Logger.warning('Failed to publish recipe update to Nostr: $e'); Logger.warning('Failed to publish recipe update to Nostr: $e');
// Don't fail the update if Nostr publish fails // Don't fail the update if Nostr publish fails
} }
} else {
if (_nostrService == null) {
Logger.warning('NostrService not available, skipping recipe update publish');
}
if (_nostrKeyPair == null) {
Logger.warning('Nostr keypair not set, skipping recipe update publish. Call setNostrKeyPair() first.');
}
} }
return updatedRecipe; return updatedRecipe;
@ -902,9 +970,24 @@ class RecipeService {
Logger.debug('Stored recipe ${recipe.id} (${recipe.title}) in database'); Logger.debug('Stored recipe ${recipe.id} (${recipe.title}) in database');
storedCount++; storedCount++;
} else { } else {
// Update existing recipe (might have newer data from Nostr) // Only update if the Nostr version is newer than the local version
await updateRecipe(recipe); // This prevents overwriting local updates with stale data from relays
storedCount++; final localUpdatedAt = existing.updatedAt;
final remoteUpdatedAt = recipe.updatedAt;
if (remoteUpdatedAt > localUpdatedAt) {
// Remote is newer, update local
await updateRecipe(recipe);
Logger.debug('Updated recipe ${recipe.id} from Nostr (remote: $remoteUpdatedAt > local: $localUpdatedAt)');
storedCount++;
} else if (remoteUpdatedAt < localUpdatedAt) {
// Local is newer, skip update to preserve local changes
Logger.debug('Skipped updating recipe ${recipe.id} from Nostr (local: $localUpdatedAt > remote: $remoteUpdatedAt)');
} else {
// Same timestamp, update anyway (might have other changes)
await updateRecipe(recipe);
storedCount++;
}
} }
} catch (e) { } catch (e) {
Logger.warning('Failed to store recipe ${recipe.id}: $e'); Logger.warning('Failed to store recipe ${recipe.id}: $e');
@ -1099,6 +1182,12 @@ class RecipeService {
/// Publishes a recipe to Nostr as a kind 30000 event (NIP-33 parameterized replaceable event). /// Publishes a recipe to Nostr as a kind 30000 event (NIP-33 parameterized replaceable event).
Future<void> _publishRecipeToNostr(RecipeModel recipe) async { Future<void> _publishRecipeToNostr(RecipeModel recipe) async {
if (_nostrService == null || _nostrKeyPair == null) { if (_nostrService == null || _nostrKeyPair == null) {
if (_nostrService == null) {
Logger.warning('_publishRecipeToNostr: NostrService is null, cannot publish recipe ${recipe.id}');
}
if (_nostrKeyPair == null) {
Logger.warning('_publishRecipeToNostr: NostrKeyPair is null, cannot publish recipe ${recipe.id}');
}
return; return;
} }
@ -1147,11 +1236,23 @@ class RecipeService {
); );
// Publish to all enabled relays // Publish to all enabled relays
final enabledRelays = _nostrService!.getRelays().where((r) => r.isEnabled).toList();
Logger.info('Publishing recipe ${recipe.id} to ${enabledRelays.length} enabled relay(s)');
if (enabledRelays.isEmpty) {
Logger.warning('No enabled relays available for publishing recipe ${recipe.id}');
}
final results = await _nostrService!.publishEventToAllRelays(event); final results = await _nostrService!.publishEventToAllRelays(event);
// Log results // Log results
final successCount = results.values.where((success) => success).length; final successCount = results.values.where((success) => success).length;
Logger.info('Published recipe ${recipe.id} to $successCount/${results.length} relays'); final totalRelays = results.length;
Logger.info('Published recipe ${recipe.id} to $successCount/$totalRelays relay(s)');
if (successCount == 0 && totalRelays > 0) {
Logger.warning('Failed to publish recipe ${recipe.id} to any relay. Results: $results');
}
// Update recipe with Nostr event ID // Update recipe with Nostr event ID
final updatedRecipe = recipe.copyWith(nostrEventId: event.id); final updatedRecipe = recipe.copyWith(nostrEventId: event.id);

@ -123,6 +123,9 @@ class SessionService {
// Set as current user // Set as current user
_currentUser = user; _currentUser = user;
// Persist session
await _saveSession();
return user; return user;
} catch (e) { } catch (e) {
throw SessionException('Failed to login: $e'); throw SessionException('Failed to login: $e');
@ -228,6 +231,9 @@ class SessionService {
// Set as current user // Set as current user
_currentUser = user; _currentUser = user;
// Persist session
await _saveSession();
// Load preferred relays from NIP-05 if available // Load preferred relays from NIP-05 if available
await _loadPreferredRelaysIfAvailable(); await _loadPreferredRelaysIfAvailable();
@ -314,6 +320,9 @@ class SessionService {
// Clear current user // Clear current user
_currentUser = null; _currentUser = null;
// Clear persisted session
await _clearSession();
// Clean up user storage references // Clean up user storage references
_userDbPaths.remove(userId); _userDbPaths.remove(userId);
_userCacheDirs.remove(userId); _userCacheDirs.remove(userId);
@ -678,5 +687,124 @@ class SessionService {
Logger.warning('Failed to load preferred relays from NIP-05: $e'); Logger.warning('Failed to load preferred relays from NIP-05: $e');
} }
} }
/// Saves the current session to persistent storage.
Future<void> _saveSession() async {
if (_currentUser == null) return;
try {
final sessionData = _currentUser!.toMap();
final item = Item(
id: 'current_session',
data: sessionData,
);
// Check if session already exists
final existing = await _localStorage.getItem('current_session');
if (existing != null) {
await _localStorage.updateItem(item);
} else {
await _localStorage.insertItem(item);
}
Logger.debug('Session saved to persistent storage');
} catch (e) {
Logger.warning('Failed to save session: $e');
// Don't throw - session persistence is not critical
}
}
/// Clears the persisted session from storage.
Future<void> _clearSession() async {
try {
await _localStorage.deleteItem('current_session');
Logger.debug('Session cleared from persistent storage');
} catch (e) {
Logger.warning('Failed to clear session: $e');
// Don't throw - clearing session is not critical
}
}
/// Restores the session from persistent storage.
///
/// This should be called during app initialization to restore the user session
/// if one was previously saved.
///
/// Returns true if a session was restored, false otherwise.
Future<bool> restoreSession() async {
if (_currentUser != null) {
Logger.debug('Session already active, skipping restore');
return true;
}
try {
final sessionItem = await _localStorage.getItem('current_session');
if (sessionItem == null || sessionItem.data.isEmpty) {
Logger.debug('No persisted session found');
return false;
}
// Restore user from stored data
final user = User.fromMap(sessionItem.data);
// If it's a Nostr login, we need to restore the Nostr keypair
if (user.nostrPrivateKey != null && _nostrService != null) {
try {
// Parse the Nostr keypair
final keyPair = NostrKeyPair.fromNsec(user.nostrPrivateKey!);
// Set Nostr keypair on RecipeService
final recipeService = ServiceLocator.instance.recipeService;
if (recipeService != null) {
recipeService.setNostrKeyPair(keyPair);
Logger.info('Nostr keypair restored on RecipeService');
}
// Set Nostr keypair on BlossomService if applicable
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService != null) {
if (mediaService is BlossomService) {
mediaService.setNostrKeyPair(keyPair);
Logger.info('Nostr keypair restored on BlossomService');
} else {
try {
(mediaService as dynamic).setNostrKeyPair(keyPair);
Logger.info('Nostr keypair restored on media service (dynamic)');
} catch (_) {
Logger.debug('Media service does not support Nostr keypair: ${mediaService.runtimeType}');
}
}
}
} catch (e) {
Logger.warning('Failed to restore Nostr keypair: $e');
// Continue with restore even if keypair restoration fails
}
}
// Create user-specific storage paths
await _setupUserStorage(user);
// Set as current user
_currentUser = user;
Logger.info('Session restored for user: ${user.username} (${user.id.substring(0, 16)}...)');
// Load preferred relays from NIP-05 if available (async, don't wait)
_loadPreferredRelaysIfAvailable().catchError((e) {
Logger.warning('Failed to load preferred relays after restore: $e');
});
return true;
} catch (e) {
Logger.warning('Failed to restore session: $e');
// Clear corrupted session data
try {
await _clearSession();
} catch (_) {
// Ignore errors when clearing
}
return false;
}
}
} }

@ -22,6 +22,7 @@ class _FavouritesScreenState extends State<FavouritesScreen> {
RecipeService? _recipeService; RecipeService? _recipeService;
bool _wasLoggedIn = false; bool _wasLoggedIn = false;
bool _isMinimalView = false; bool _isMinimalView = false;
bool _hasChanges = false; // Track if any changes were made
@override @override
void initState() { void initState() {
@ -138,6 +139,7 @@ class _FavouritesScreenState extends State<FavouritesScreen> {
try { try {
await _recipeService!.deleteRecipe(recipe.id); await _recipeService!.deleteRecipe(recipe.id);
if (mounted) { if (mounted) {
_hasChanges = true; // Mark that changes were made
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Recipe deleted successfully'), content: Text('Recipe deleted successfully'),
@ -190,6 +192,7 @@ class _FavouritesScreenState extends State<FavouritesScreen> {
final updatedRecipe = recipe.copyWith(isFavourite: !recipe.isFavourite); final updatedRecipe = recipe.copyWith(isFavourite: !recipe.isFavourite);
await _recipeService!.updateRecipe(updatedRecipe); await _recipeService!.updateRecipe(updatedRecipe);
if (mounted) { if (mounted) {
_hasChanges = true; // Mark that changes were made
_loadFavourites(); _loadFavourites();
} }
} catch (e) { } catch (e) {
@ -207,23 +210,32 @@ class _FavouritesScreenState extends State<FavouritesScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return PopScope(
appBar: AppBar( canPop: false,
title: const Text('Favourites'), onPopInvokedWithResult: (didPop, result) async {
elevation: 0, // When navigating back, return true if changes were made
actions: [ if (!didPop) {
// View mode toggle icon Navigator.of(context).pop(_hasChanges);
IconButton( }
icon: Icon(_isMinimalView ? Icons.grid_view : Icons.view_list), },
onPressed: () { child: Scaffold(
setState(() { appBar: AppBar(
_isMinimalView = !_isMinimalView; title: const Text('Favourites'),
}); elevation: 0,
}, actions: [
), // View mode toggle icon
], IconButton(
icon: Icon(_isMinimalView ? Icons.grid_view : Icons.view_list),
onPressed: () {
setState(() {
_isMinimalView = !_isMinimalView;
});
},
),
],
),
body: _buildBody(),
), ),
body: _buildBody(),
); );
} }

@ -19,7 +19,7 @@ class MainNavigationScaffold extends StatefulWidget {
} }
class MainNavigationScaffoldState extends State<MainNavigationScaffold> { class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
int _currentIndex = 0; late int _currentIndex;
bool _wasLoggedIn = false; bool _wasLoggedIn = false;
bool get _isLoggedIn => ServiceLocator.instance.sessionService?.isLoggedIn ?? false; bool get _isLoggedIn => ServiceLocator.instance.sessionService?.isLoggedIn ?? false;
@ -27,7 +27,10 @@ class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_wasLoggedIn = _isLoggedIn; final isLoggedIn = _isLoggedIn;
_wasLoggedIn = isLoggedIn;
// Show login screen (index 3) if not logged in, otherwise show Home (index 0)
_currentIndex = isLoggedIn ? 0 : 3;
} }
@override @override
@ -95,20 +98,24 @@ class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
} }
/// Public method to navigate to Favourites screen (used from Recipes AppBar). /// Public method to navigate to Favourites screen (used from Recipes AppBar).
void navigateToFavourites() { /// Returns true if any changes were made that require refreshing the Recipes list.
Future<bool> navigateToFavourites() async {
final isLoggedIn = _isLoggedIn; final isLoggedIn = _isLoggedIn;
if (!isLoggedIn) { if (!isLoggedIn) {
// Redirect to User/Session tab to prompt login // Redirect to User/Session tab to prompt login
navigateToUser(); navigateToUser();
return; return false;
} }
// Navigate to Favourites as a full-screen route // Navigate to Favourites as a full-screen route and await result
Navigator.push( // FavouritesScreen will return true if any changes were made
final result = await Navigator.push<bool>(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const FavouritesScreen(), builder: (context) => const FavouritesScreen(),
), ),
); );
// Return true if changes were made (to trigger refresh in RecipesScreen)
return result ?? false;
} }
void _onAddRecipePressed(BuildContext context) async { void _onAddRecipePressed(BuildContext context) async {
@ -180,7 +187,18 @@ class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_currentIndex = 0; // Switch to Home if trying to access protected tabs _currentIndex = 3; // Switch to Session/Login screen if trying to access protected tabs
});
}
});
}
// If logged out and on Home screen, redirect to login
if (!isLoggedIn && _currentIndex == 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_currentIndex = 3; // Switch to Session/Login screen
}); });
} }
}); });

@ -27,7 +27,7 @@ class RecipesScreen extends StatefulWidget {
} }
} }
class _RecipesScreenState extends State<RecipesScreen> { class _RecipesScreenState extends State<RecipesScreen> with WidgetsBindingObserver {
List<RecipeModel> _recipes = []; List<RecipeModel> _recipes = [];
List<RecipeModel> _filteredRecipes = []; List<RecipeModel> _filteredRecipes = [];
bool _isLoading = false; bool _isLoading = false;
@ -37,10 +37,12 @@ class _RecipesScreenState extends State<RecipesScreen> {
bool _isMinimalView = false; bool _isMinimalView = false;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
bool _isSearching = false; bool _isSearching = false;
DateTime? _lastRefreshTime;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_initializeService(); _initializeService();
_checkLoginState(); _checkLoginState();
_loadPreferences(); _loadPreferences();
@ -49,10 +51,20 @@ class _RecipesScreenState extends State<RecipesScreen> {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_searchController.dispose(); _searchController.dispose();
super.dispose(); super.dispose();
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// Refresh recipes when app resumes to ensure UI is up-to-date
if (state == AppLifecycleState.resumed && _recipeService != null) {
_refreshIfNeeded();
}
}
Future<void> _loadPreferences() async { Future<void> _loadPreferences() async {
try { try {
final localStorage = ServiceLocator.instance.localStorageService; final localStorage = ServiceLocator.instance.localStorageService;
@ -111,7 +123,20 @@ class _RecipesScreenState extends State<RecipesScreen> {
super.didChangeDependencies(); super.didChangeDependencies();
_checkLoginState(); _checkLoginState();
if (_recipeService != null) { if (_recipeService != null) {
_loadRecipes(fetchFromNostr: false); // Refresh when dependencies change (e.g., returning from another screen)
_refreshIfNeeded();
}
}
/// Refreshes recipes if enough time has passed since last refresh.
/// This prevents excessive refreshes while ensuring UI stays up-to-date.
void _refreshIfNeeded() {
final now = DateTime.now();
// Refresh if never refreshed, or if more than 1 second has passed
if (_lastRefreshTime == null ||
now.difference(_lastRefreshTime!).inSeconds > 1) {
_lastRefreshTime = now;
_loadRecipes(fetchFromNostr: false, showLoading: false);
} }
} }
@ -165,15 +190,42 @@ class _RecipesScreenState extends State<RecipesScreen> {
} }
} }
Future<void> _loadRecipes({bool fetchFromNostr = false}) async { Future<void> _loadRecipes({bool fetchFromNostr = false, bool clearImageCache = false, bool showLoading = true}) async {
if (_recipeService == null) return; if (_recipeService == null) return;
setState(() { if (showLoading) {
_isLoading = true; setState(() {
_errorMessage = null; _isLoading = true;
}); _errorMessage = null;
});
}
try { try {
// Clear image cache if requested (e.g., on pull-to-refresh)
if (clearImageCache) {
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService != null) {
try {
// Clear cache for all recipe images
final recipes = await _recipeService!.getAllRecipes();
for (final recipe in recipes) {
for (final imageUrl in recipe.imageUrls) {
try {
await mediaService.clearImageCache(imageUrl);
} catch (e) {
// Ignore individual cache clear failures
Logger.debug('Failed to clear cache for $imageUrl: $e');
}
}
}
Logger.info('Cleared image cache for ${recipes.length} recipe(s)');
} catch (e) {
Logger.warning('Failed to clear image cache: $e');
// Don't fail the load if cache clearing fails
}
}
}
if (fetchFromNostr) { if (fetchFromNostr) {
final sessionService = ServiceLocator.instance.sessionService; final sessionService = ServiceLocator.instance.sessionService;
if (sessionService != null && sessionService.currentUser != null) { if (sessionService != null && sessionService.currentUser != null) {
@ -196,7 +248,9 @@ class _RecipesScreenState extends State<RecipesScreen> {
setState(() { setState(() {
_recipes = recipes; _recipes = recipes;
_filteredRecipes = List.from(recipes); _filteredRecipes = List.from(recipes);
_isLoading = false; if (showLoading) {
_isLoading = false;
}
}); });
_filterRecipes(); // Apply current search filter _filterRecipes(); // Apply current search filter
} }
@ -217,8 +271,12 @@ class _RecipesScreenState extends State<RecipesScreen> {
builder: (context) => AddRecipeScreen(recipe: recipe, viewMode: true), builder: (context) => AddRecipeScreen(recipe: recipe, viewMode: true),
), ),
); );
// Always reload recipes after viewing/editing to ensure UI is up-to-date
// Don't fetch from Nostr immediately after editing - relays may not have synced yet
// The local database already has the updated recipe, so just reload from local
// Skip loading indicator for instant UI update
if (mounted) { if (mounted) {
_loadRecipes(); await _loadRecipes(fetchFromNostr: false, showLoading: false);
} }
} }
@ -315,10 +373,14 @@ class _RecipesScreenState extends State<RecipesScreen> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.favorite), icon: const Icon(Icons.favorite),
onPressed: () { onPressed: () async {
// Navigate to Favourites screen // Navigate to Favourites screen and refresh if changes were made
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>(); final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToFavourites(); final hasChanges = await scaffold?.navigateToFavourites() ?? false;
// Refresh recipes list if any changes were made in Favourites screen
if (hasChanges && mounted && _recipeService != null) {
await _loadRecipes(fetchFromNostr: false, showLoading: false);
}
}, },
tooltip: 'Favourites', tooltip: 'Favourites',
), ),
@ -394,7 +456,7 @@ class _RecipesScreenState extends State<RecipesScreen> {
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => _loadRecipes(fetchFromNostr: true), onRefresh: () => _loadRecipes(fetchFromNostr: true, clearImageCache: true),
child: _filteredRecipes.isEmpty child: _filteredRecipes.isEmpty
? SingleChildScrollView( ? SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),

@ -37,7 +37,8 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
MultiMediaService? _multiMediaService; MultiMediaService? _multiMediaService;
List<MediaServerConfig> _mediaServers = []; List<MediaServerConfig> _mediaServers = [];
MediaServerConfig? _defaultMediaServer; MediaServerConfig? _defaultMediaServer;
bool _mediaServersExpanded = false; // Track expansion state bool _mediaServersExpanded = false;
bool _settingsExpanded = true; // Settings expanded by default
// Store original values to detect changes // Store original values to detect changes
List<MediaServerConfig> _originalMediaServers = []; List<MediaServerConfig> _originalMediaServers = [];
@ -504,180 +505,165 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
// Settings section // Settings section - Now using ExpansionTile for uniformity
Container( ExpansionTile(
width: double.infinity, title: const Text('Settings'),
padding: const EdgeInsets.all(16), subtitle: _hasUnsavedChanges
decoration: BoxDecoration( ? const Text('You have unsaved changes', style: TextStyle(color: Colors.orange))
color: Theme.of(context).cardColor, : Text('${_useNip05RelaysAutomatically ? "NIP-05" : "Manual"} relays • ${_isDarkMode ? "Dark" : "Light"} mode'),
border: Border( initiallyExpanded: _settingsExpanded,
bottom: BorderSide( trailing: _hasUnsavedChanges
color: Theme.of(context).dividerColor, ? ElevatedButton.icon(
width: 1, onPressed: _saveAllSettings,
), icon: const Icon(Icons.save, size: 18),
), label: const Text('Save'),
), style: ElevatedButton.styleFrom(
child: Column( backgroundColor: Colors.green,
crossAxisAlignment: CrossAxisAlignment.start, foregroundColor: Colors.white,
children: [ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Settings',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
ElevatedButton.icon(
onPressed: _hasUnsavedChanges ? _saveAllSettings : null,
icon: const Icon(Icons.save, size: 18),
label: const Text('Save'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
),
],
),
if (_hasUnsavedChanges)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'You have unsaved changes',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.orange,
fontStyle: FontStyle.italic,
),
), ),
),
const SizedBox(height: 12),
if (_isLoadingSetting)
const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Loading settings...'),
],
) )
else : null,
SwitchListTile( onExpansionChanged: (expanded) {
title: const Text('Use NIP-05 relays automatically'), setState(() {
subtitle: const Text( _settingsExpanded = expanded;
'Automatically replace relays with NIP-05 preferred relays upon login', });
style: TextStyle(fontSize: 12), },
), children: [
value: _useNip05RelaysAutomatically, Padding(
onChanged: (value) { padding: const EdgeInsets.all(16),
_saveSetting('use_nip05_relays_automatically', value); child: Column(
}, crossAxisAlignment: CrossAxisAlignment.start,
contentPadding: EdgeInsets.zero, children: [
), if (_isLoadingSetting)
const SizedBox(height: 8), const Row(
SwitchListTile(
title: const Text('Dark Mode'),
subtitle: const Text(
'Enable dark theme for the app',
style: TextStyle(fontSize: 12),
),
value: _isDarkMode,
onChanged: (value) {
_saveSetting('dark_mode', value);
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
// Media Server Settings - Multiple Servers
Builder(
builder: (context) {
final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com';
return ExpansionTile(
key: ValueKey('media-servers-${_mediaServers.length}-${_mediaServersExpanded}'),
title: const Text('Media Servers'),
subtitle: Text(_mediaServers.isEmpty
? 'No servers configured'
: '${_mediaServers.length} server(s)${_defaultMediaServer != null ? " • Default: ${_defaultMediaServer!.name ?? _defaultMediaServer!.type}" : ""}'),
initiallyExpanded: _mediaServersExpanded,
onExpansionChanged: (expanded) {
setState(() {
_mediaServersExpanded = expanded;
});
},
children: [ children: [
// Server list SizedBox(
if (_mediaServers.isEmpty) width: 16,
Padding( height: 16,
padding: const EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2),
child: Text( ),
'No media servers configured. Add one to get started.', SizedBox(width: 12),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( Text('Loading settings...'),
color: Colors.grey, ],
), )
), else ...[
) SwitchListTile(
else title: const Text('Use NIP-05 relays automatically'),
...List.generate(_mediaServers.length, (index) { subtitle: const Text(
final server = _mediaServers[index]; 'Automatically replace relays with NIP-05 preferred relays upon login',
return ListTile( style: TextStyle(fontSize: 12),
key: ValueKey(server.id), ),
leading: Icon( value: _useNip05RelaysAutomatically,
server.isDefault ? Icons.star : Icons.star_border, onChanged: (value) {
color: server.isDefault ? Colors.amber : Colors.grey, _saveSetting('use_nip05_relays_automatically', value);
), },
title: Text(server.name ?? '${server.type.toUpperCase()} Server'), contentPadding: EdgeInsets.zero,
subtitle: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, const SizedBox(height: 8),
children: [ SwitchListTile(
Text(server.baseUrl), title: const Text('Dark Mode'),
if (server.isDefault) subtitle: const Text(
Padding( 'Enable dark theme for the app',
padding: const EdgeInsets.only(top: 4.0), style: TextStyle(fontSize: 12),
child: Chip( ),
label: const Text('Default'), value: _isDarkMode,
labelStyle: const TextStyle(fontSize: 10), onChanged: (value) {
padding: EdgeInsets.zero, _saveSetting('dark_mode', value);
), },
), contentPadding: EdgeInsets.zero,
], ),
), const SizedBox(height: 16),
trailing: Row( // Media Server Settings - Multiple Servers
mainAxisSize: MainAxisSize.min, Builder(
children: [ builder: (context) {
IconButton( final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
icon: const Icon(Icons.edit), final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com';
onPressed: () => _editMediaServer(server),
return ExpansionTile(
key: ValueKey('media-servers-${_mediaServers.length}-${_mediaServersExpanded}'),
title: const Text('Media Servers'),
subtitle: Text(_mediaServers.isEmpty
? 'No servers configured'
: '${_mediaServers.length} server(s)${_defaultMediaServer != null ? " • Default: ${_defaultMediaServer!.name ?? _defaultMediaServer!.type}" : ""}'),
initiallyExpanded: _mediaServersExpanded,
onExpansionChanged: (expanded) {
setState(() {
_mediaServersExpanded = expanded;
});
},
children: [
// Server list
if (_mediaServers.isEmpty)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'No media servers configured. Add one to get started.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
), ),
IconButton( ),
icon: const Icon(Icons.delete), )
onPressed: () => _removeMediaServer(server), else
...List.generate(_mediaServers.length, (index) {
final server = _mediaServers[index];
return ListTile(
key: ValueKey(server.id),
leading: Icon(
server.isDefault ? Icons.star : Icons.star_border,
color: server.isDefault ? Colors.amber : Colors.grey,
),
title: Text(server.name ?? '${server.type.toUpperCase()} Server'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(server.baseUrl),
if (server.isDefault)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Chip(
label: const Text('Default'),
labelStyle: const TextStyle(fontSize: 10),
padding: EdgeInsets.zero,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _editMediaServer(server),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _removeMediaServer(server),
),
],
), ),
], onTap: () => _setDefaultServer(server),
);
}),
const Divider(),
// Add server button
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
onPressed: () => _addMediaServer(immichEnabled, defaultBlossomServer),
icon: const Icon(Icons.add),
label: const Text('Add Media Server'),
), ),
onTap: () => _setDefaultServer(server), ),
); ],
}), );
const Divider(), },
// Add server button ),
Padding( ],
padding: const EdgeInsets.all(16.0), ],
child: ElevatedButton.icon( ),
onPressed: () => _addMediaServer(immichEnabled, defaultBlossomServer), ),
icon: const Icon(Icons.add), ],
label: const Text('Add Media Server'),
),
),
],
);
},
),
],
),
), ),
// Error message // Error message
if (widget.controller.error != null) if (widget.controller.error != null)

@ -31,6 +31,10 @@ class _SessionScreenState extends State<SessionScreen> {
bool _useFirebaseAuth = false; bool _useFirebaseAuth = false;
bool _useNostrLogin = false; bool _useNostrLogin = false;
NostrKeyPair? _generatedKeyPair; NostrKeyPair? _generatedKeyPair;
Uint8List? _avatarBytes;
bool _isLoadingAvatar = false;
String? _lastProfilePictureUrl; // Track URL to avoid reloading
DateTime? _avatarLastLoaded; // Track when avatar was last loaded
@override @override
void initState() { void initState() {
@ -38,6 +42,30 @@ class _SessionScreenState extends State<SessionScreen> {
// Check if Firebase Auth is available // Check if Firebase Auth is available
_useFirebaseAuth = ServiceLocator.instance.firebaseService?.isEnabled == true && _useFirebaseAuth = ServiceLocator.instance.firebaseService?.isEnabled == true &&
ServiceLocator.instance.firebaseService?.config.authEnabled == true; ServiceLocator.instance.firebaseService?.config.authEnabled == true;
_loadAvatar();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Load avatar if not already loaded or if URL changed
final sessionService = ServiceLocator.instance.sessionService;
if (sessionService != null && sessionService.isLoggedIn) {
final currentUser = sessionService.currentUser;
final profilePictureUrl = currentUser?.nostrProfile?.picture;
// Load if URL changed or if we don't have cached bytes yet
if (profilePictureUrl != _lastProfilePictureUrl ||
(profilePictureUrl != null && _avatarBytes == null && !_isLoadingAvatar)) {
_loadAvatar();
}
} else if (_avatarBytes != null) {
// Clear avatar if logged out
setState(() {
_avatarBytes = null;
_lastProfilePictureUrl = null;
_avatarLastLoaded = null;
});
}
} }
@override @override
@ -96,6 +124,7 @@ class _SessionScreenState extends State<SessionScreen> {
), ),
); );
setState(() {}); setState(() {});
_loadAvatar(); // Reload avatar after login
widget.onSessionChanged?.call(); widget.onSessionChanged?.call();
} }
return; return;
@ -138,6 +167,7 @@ class _SessionScreenState extends State<SessionScreen> {
), ),
); );
setState(() {}); setState(() {});
_loadAvatar(); // Reload avatar after login
// Notify parent that session state changed // Notify parent that session state changed
widget.onSessionChanged?.call(); widget.onSessionChanged?.call();
} }
@ -194,6 +224,7 @@ class _SessionScreenState extends State<SessionScreen> {
), ),
); );
setState(() {}); setState(() {});
_loadAvatar(); // Reload avatar after login
// Notify parent that session state changed // Notify parent that session state changed
widget.onSessionChanged?.call(); widget.onSessionChanged?.call();
} }
@ -243,7 +274,10 @@ class _SessionScreenState extends State<SessionScreen> {
content: Text('Logout successful'), content: Text('Logout successful'),
), ),
); );
setState(() {}); setState(() {
_avatarBytes = null; // Clear avatar on logout
_lastProfilePictureUrl = null;
});
// Notify parent that session state changed // Notify parent that session state changed
widget.onSessionChanged?.call(); widget.onSessionChanged?.call();
} }
@ -265,7 +299,132 @@ class _SessionScreenState extends State<SessionScreen> {
} }
} }
/// Builds a profile picture widget using ImmichService for authenticated access. /// Loads the avatar image and caches it in state.
Future<void> _loadAvatar() async {
final sessionService = ServiceLocator.instance.sessionService;
if (sessionService == null || !sessionService.isLoggedIn) {
// Clear avatar when logged out
if (mounted) {
setState(() {
_avatarBytes = null;
_isLoadingAvatar = false;
_lastProfilePictureUrl = null;
});
}
return;
}
final currentUser = sessionService.currentUser;
if (currentUser == null) {
if (mounted) {
setState(() {
_avatarBytes = null;
_isLoadingAvatar = false;
_lastProfilePictureUrl = null;
});
}
return;
}
final profilePictureUrl = currentUser.nostrProfile?.picture;
if (profilePictureUrl == null || profilePictureUrl.isEmpty) {
// Clear avatar if no profile picture
if (mounted) {
setState(() {
_avatarBytes = null;
_isLoadingAvatar = false;
_lastProfilePictureUrl = null;
});
}
return;
}
// Don't reload if it's the same URL and we already have the bytes in memory
// Only reload if it's been more than 5 minutes since last load
// Note: MediaService handles disk caching, so we can rely on it for persistence
final shouldReload = profilePictureUrl != _lastProfilePictureUrl ||
_avatarBytes == null ||
(_avatarLastLoaded != null &&
DateTime.now().difference(_avatarLastLoaded!).inMinutes > 5);
if (!shouldReload && _avatarBytes != null) {
return;
}
// Update last URL before loading (even if it fails, we don't want to retry immediately)
_lastProfilePictureUrl = profilePictureUrl;
if (mounted) {
setState(() {
_isLoadingAvatar = true;
});
}
try {
// Extract asset ID from Immich URL using regex
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService != null) {
// Try to extract asset ID from URL (format: .../api/assets/{id}/original)
final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(profilePictureUrl);
String? assetId;
if (assetIdMatch != null) {
assetId = assetIdMatch.group(1);
} else {
// For Blossom URLs, use the full URL
assetId = profilePictureUrl;
}
if (assetId != null) {
// Use MediaService which has built-in disk caching
// MediaService will return cached bytes if available, otherwise fetch and cache
try {
final bytes = await mediaService.fetchImageBytes(assetId, isThumbnail: true);
if (mounted && bytes != null && bytes.isNotEmpty) {
setState(() {
_avatarBytes = bytes;
_isLoadingAvatar = false;
_avatarLastLoaded = DateTime.now();
});
return;
}
} catch (e) {
// If fetch fails (e.g., 404), log but don't mark URL as loaded
// This allows retry on next refresh if the URL becomes available
Logger.warning('Failed to fetch avatar from MediaService: $e');
if (mounted) {
setState(() {
_isLoadingAvatar = false;
_avatarBytes = null;
// Don't update _lastProfilePictureUrl on error - allows retry
// Only update if we successfully loaded
});
}
return; // Return early to avoid showing placeholder immediately
}
}
}
// Fallback: try to fetch as regular image (for non-Immich URLs or shared links)
// For now, we'll just set loading to false
if (mounted) {
setState(() {
_isLoadingAvatar = false;
});
}
} catch (e) {
// Log error for debugging
Logger.warning('Failed to load avatar: $e');
if (mounted) {
setState(() {
_isLoadingAvatar = false;
_avatarBytes = null; // Clear on error
});
}
}
}
/// Builds a profile picture widget using cached avatar bytes.
Widget _buildProfilePicture(String? imageUrl, {double radius = 30}) { Widget _buildProfilePicture(String? imageUrl, {double radius = 30}) {
if (imageUrl == null) { if (imageUrl == null) {
return CircleAvatar( return CircleAvatar(
@ -274,60 +433,34 @@ class _SessionScreenState extends State<SessionScreen> {
); );
} }
final mediaService = ServiceLocator.instance.mediaService; if (_isLoadingAvatar) {
if (mediaService == null) {
return CircleAvatar( return CircleAvatar(
radius: radius, radius: radius,
backgroundColor: Colors.grey[300], backgroundColor: Colors.grey[300],
child: const Icon(Icons.person, size: 50), child: const CircularProgressIndicator(),
); );
} }
// Try to extract asset ID from Immich URL (format: .../api/assets/{id}/original)
final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl);
String? assetId;
if (assetIdMatch != null) {
assetId = assetIdMatch.group(1);
} else {
// For Blossom URLs, use the full URL
assetId = imageUrl;
}
if (assetId != null) {
// Use MediaService to fetch image with proper authentication
return FutureBuilder<Uint8List?>(
future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircleAvatar(
radius: radius,
backgroundColor: Colors.grey[300],
child: const CircularProgressIndicator(),
);
}
if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { if (_avatarBytes != null) {
return CircleAvatar( return CircleAvatar(
radius: radius, radius: radius,
backgroundColor: Colors.grey[300], backgroundImage: MemoryImage(_avatarBytes!),
child: const Icon(Icons.broken_image), onBackgroundImageError: (_, __) {
); // Clear avatar on error
} if (mounted) {
setState(() {
return CircleAvatar( _avatarBytes = null;
radius: radius, });
backgroundImage: MemoryImage(snapshot.data!), }
); },
}, );
);
} }
// Fallback to direct network image if not an Immich URL or service unavailable // Fallback: show placeholder if no cached bytes
return CircleAvatar( return CircleAvatar(
radius: radius, radius: radius,
backgroundImage: NetworkImage(imageUrl), backgroundColor: Colors.grey[300],
onBackgroundImageError: (_, __) {}, child: const Icon(Icons.person, size: 50),
); );
} }
@ -342,6 +475,7 @@ class _SessionScreenState extends State<SessionScreen> {
// If profile was updated, refresh the screen // If profile was updated, refresh the screen
if (result == true && mounted) { if (result == true && mounted) {
setState(() {}); setState(() {});
_loadAvatar(); // Reload avatar after profile update
// Also trigger the session changed callback to update parent // Also trigger the session changed callback to update parent
widget.onSessionChanged?.call(); widget.onSessionChanged?.call();
} }
@ -351,9 +485,36 @@ class _SessionScreenState extends State<SessionScreen> {
if (ServiceLocator.instance.sessionService == null) return; if (ServiceLocator.instance.sessionService == null) return;
try { try {
await ServiceLocator.instance.sessionService!.refreshNostrProfile(); // Store the old profile picture URL to detect changes
final sessionService = ServiceLocator.instance.sessionService!;
final oldProfilePictureUrl = sessionService.currentUser?.nostrProfile?.picture;
await sessionService.refreshNostrProfile();
if (mounted) { if (mounted) {
setState(() {}); // Wait for relays to connect and profile to fully sync
// The profile refresh triggers relay reconnection which takes time
await Future.delayed(const Duration(milliseconds: 800));
// Get the new profile picture URL after refresh
final newProfilePictureUrl = sessionService.currentUser?.nostrProfile?.picture;
// Clear in-memory cache to force reload from MediaService disk cache or network
setState(() {
_avatarBytes = null;
_avatarLastLoaded = null;
// Only clear last URL if it actually changed, otherwise keep it to allow cache lookup
if (oldProfilePictureUrl != newProfilePictureUrl) {
_lastProfilePictureUrl = null;
}
});
// Only reload avatar if we have a valid URL
if (newProfilePictureUrl != null && newProfilePictureUrl.isNotEmpty) {
// Reload avatar after profile refresh - MediaService will use disk cache if available
await _loadAvatar();
}
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Session data refreshed'), content: Text('Session data refreshed'),
@ -550,6 +711,18 @@ class _SessionScreenState extends State<SessionScreen> {
), ),
], ],
const SizedBox(height: 24), const SizedBox(height: 24),
// No Nostr account text
if (_useNostrLogin)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'No Nostr account?',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
),
// Login method selector // Login method selector
SegmentedButton<bool>( SegmentedButton<bool>(
segments: const [ segments: const [
@ -583,7 +756,7 @@ class _SessionScreenState extends State<SessionScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( const Text(
'Generate Key Pair', 'Generate a Key pair',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

@ -18,7 +18,7 @@ import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))

@ -5,26 +5,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "85.0.0" version: "91.0.0"
_flutterfire_internals: _flutterfire_internals:
dependency: transitive dependency: transitive
description: description:
name: _flutterfire_internals name: _flutterfire_internals
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 sha256: "9371d13b8ee442e3bfc08a24e3a1b3742c839abbfaf5eef11b79c4b862c89bf7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.59" version: "1.3.41"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.7.1" version: "8.4.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -117,10 +117,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build name: build
sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "4.0.2"
build_config: build_config:
dependency: transitive dependency: transitive
description: description:
@ -133,34 +133,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build_daemon name: build_daemon
sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.0"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "4.1.1"
build_runner: build_runner:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: build_runner name: build_runner
sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30 sha256: "04f69b1502f66e22ae7990bbd01eb552b7f12793c4d3ea6e715d0ac5e98bcdac"
url: "https://pub.dev"
source: hosted
version: "2.7.1"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.3.1" version: "2.10.2"
built_collection: built_collection:
dependency: transitive dependency: transitive
description: description:
@ -213,26 +197,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: cloud_firestore name: cloud_firestore
sha256: "2d33da4465bdb81b6685c41b535895065adcb16261beb398f5f3bbc623979e9c" sha256: "0af4f1e0b7f82a2082ad2c886b042595d85c7641616688609dd999d33aeef850"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.6.12" version: "5.4.0"
cloud_firestore_platform_interface: cloud_firestore_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: cloud_firestore_platform_interface name: cloud_firestore_platform_interface
sha256: "413c4e01895cf9cb3de36fa5c219479e06cd4722876274ace5dfc9f13ab2e39b" sha256: "6e82bcaf2b8e33f312dfc7c5e5855376fee538467dd6e58dc41bd13996ff5d64"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.12" version: "6.4.0"
cloud_firestore_web: cloud_firestore_web:
dependency: transitive dependency: transitive
description: description:
name: cloud_firestore_web name: cloud_firestore_web
sha256: c1e30fc4a0fcedb08723fb4b1f12ee4e56d937cbf9deae1bda43cbb6367bb4cf sha256: "38aec6ed732980dea6eec192a25fb55665137edc5c356603d8dc26ff5971f4d8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.4.12" version: "4.2.0"
code_builder: code_builder:
dependency: transitive dependency: transitive
description: description:
@ -285,10 +269,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.1" version: "3.1.2"
diff_match_patch: diff_match_patch:
dependency: transitive dependency: transitive
description: description:
@ -357,10 +341,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_selector_platform_interface name: file_selector_platform_interface
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.2" version: "2.7.0"
file_selector_windows: file_selector_windows:
dependency: transitive dependency: transitive
description: description:
@ -373,122 +357,122 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_analytics name: firebase_analytics
sha256: "4f85b161772e1d54a66893ef131c0a44bd9e552efa78b33d5f4f60d2caa5c8a3" sha256: "7e032ade38dec2a92f543ba02c5f72f54ffaa095c60d2132b867eab56de3bc73"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.6.0" version: "11.3.0"
firebase_analytics_platform_interface: firebase_analytics_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_analytics_platform_interface name: firebase_analytics_platform_interface
sha256: a44b6d1155ed5cae7641e3de7163111cfd9f6f6c954ca916dc6a3bdfa86bf845 sha256: b62a2444767d95067a7e36b1d6e335e0b877968574bbbfb656168c46f2e95a13
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.4.3" version: "4.2.2"
firebase_analytics_web: firebase_analytics_web:
dependency: transitive dependency: transitive
description: description:
name: firebase_analytics_web name: firebase_analytics_web
sha256: c7d1ed1f86ae64215757518af5576ff88341c8ce5741988c05cc3b2e07b0b273 sha256: bad44f71f96cfca6c16c9dd4f70b85f123ddca7d5dd698977449fadf298b1782
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.10+16" version: "0.5.9+2"
firebase_auth: firebase_auth:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_auth name: firebase_auth
sha256: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff" sha256: "6f5792bdc208416bfdfbfe3363b78ce01667b6ebc4c5cb47cfa891f2fca45ab7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.7.0" version: "5.2.0"
firebase_auth_platform_interface: firebase_auth_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_auth_platform_interface name: firebase_auth_platform_interface
sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386" sha256: "80237bb8a92bb0a5e3b40de1c8dbc80254e49ac9e3907b4b47b8e95ac3dd3fad"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.7.3" version: "7.4.4"
firebase_auth_web: firebase_auth_web:
dependency: transitive dependency: transitive
description: description:
name: firebase_auth_web name: firebase_auth_web
sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e sha256: "9d315491a6be65ea83511cb0e078544a309c39dd54c0ee355c51dbd6d8c03cc8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.15.3" version: "5.12.6"
firebase_core: firebase_core:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_core name: firebase_core
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" sha256: "06537da27db981947fa535bb91ca120b4e9cb59cb87278dbdde718558cafc9ff"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.15.2" version: "3.4.0"
firebase_core_platform_interface: firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: firebase_core_platform_interface
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 sha256: "8bcfad6d7033f5ea951d15b867622a824b13812178bfec0c779b9d81de011bbb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.2" version: "5.4.2"
firebase_core_web: firebase_core_web:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: firebase_core_web
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" sha256: "362e52457ed2b7b180964769c1e04d1e0ea0259fdf7025fdfedd019d4ae2bd88"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.24.1" version: "2.17.5"
firebase_messaging: firebase_messaging:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_messaging name: firebase_messaging
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" sha256: "29941ba5a3204d80656c0e52103369aa9a53edfd9ceae05a2bb3376f24fda453"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.10" version: "15.1.0"
firebase_messaging_platform_interface: firebase_messaging_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_messaging_platform_interface name: firebase_messaging_platform_interface
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" sha256: "26c5370d3a79b15c8032724a68a4741e28f63e1f1a45699c4f0a8ae740aadd72"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.10" version: "4.5.43"
firebase_messaging_web: firebase_messaging_web:
dependency: transitive dependency: transitive
description: description:
name: firebase_messaging_web name: firebase_messaging_web
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" sha256: "58276cd5d9e22a9320ef9e5bc358628920f770f93c91221f8b638e8346ed5df4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.10.10" version: "3.8.13"
firebase_storage: firebase_storage:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_storage name: firebase_storage
sha256: "958fc88a7ef0b103e694d30beed515c8f9472dde7e8459b029d0e32b8ff03463" sha256: dfc06d783dbc0b6200a4b936d8cdbd826bd1571c959854d14a70259188d96e85
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.4.10" version: "12.2.0"
firebase_storage_platform_interface: firebase_storage_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_storage_platform_interface name: firebase_storage_platform_interface
sha256: d2661c05293c2a940c8ea4bc0444e1b5566c79dd3202c2271140c082c8cd8dd4 sha256: "3da511301b77514dee5370281923fbbc6d5725c2a0b96004c5c45415e067f234"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.10" version: "5.1.28"
firebase_storage_web: firebase_storage_web:
dependency: transitive dependency: transitive
description: description:
name: firebase_storage_web name: firebase_storage_web
sha256: "629a557c5e1ddb97a3666cbf225e97daa0a66335dbbfdfdce113ef9f881e833f" sha256: "7ad67b1c1c46c995a6bd4f225d240fc9a5fb277fade583631ae38750ffd9be17"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.10.17" version: "3.9.13"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -564,10 +548,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: http name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.0" version: "1.6.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@ -588,10 +572,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker name: image_picker
sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.2.1"
image_picker_android: image_picker_android:
dependency: transitive dependency: transitive
description: description:
@ -732,10 +716,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@ -748,10 +732,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: mockito name: mockito
sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" sha256: "4feb43bc4eb6c03e832f5fcd637d1abb44b98f9cfa245c58e27382f58859f8f6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.5.0" version: "5.5.1"
mocktail: mocktail:
dependency: transitive dependency: transitive
description: description:
@ -929,10 +913,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_gen name: source_gen
sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "4.0.2"
source_map_stack_trace: source_map_stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -985,10 +969,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: sqflite_common_ffi name: sqflite_common_ffi
sha256: "9faa2fedc5385ef238ce772589f7718c24cdddd27419b609bb9c6f703ea27988" sha256: "1f3ef3888d3bfbb47785cc1dda0dc7dd7ebd8c1955d32a9e8e9dae1e38d1c4c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.6" version: "2.3.5"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@ -1009,10 +993,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlite3 name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" sha256: fde692580bee3379374af1f624eb3e113ab2865ecb161dbe2d8ac2de9735dbdb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.9.4" version: "2.4.5"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -1065,34 +1049,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.26.2" version: "1.26.3"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.11" version: "0.6.12"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -1129,18 +1105,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "0.5.1"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: transitive
description: description:
name: web_socket_channel name: web_socket_channel
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.5"
webkit_inspection_protocol: webkit_inspection_protocol:
dependency: transitive dependency: transitive
description: description:

Loading…
Cancel
Save

Powered by TurnKey Linux.