From 52c449356a23760f8ba16b5897b8f510f0db1ae7 Mon Sep 17 00:00:00 2001 From: gitea Date: Wed, 12 Nov 2025 17:30:40 +0100 Subject: [PATCH] bookmarks feature fix --- lib/data/immich/immich_service.dart | 65 ++++ lib/data/recipes/recipe_service.dart | 468 +++++++++++++++++++++++- lib/data/session/session_service.dart | 35 ++ lib/ui/session/session_screen.dart | 126 +++++-- lib/ui/user_edit/user_edit_screen.dart | 479 +++++++++++++++++++++++++ 5 files changed, 1144 insertions(+), 29 deletions(-) create mode 100644 lib/ui/user_edit/user_edit_screen.dart diff --git a/lib/data/immich/immich_service.dart b/lib/data/immich/immich_service.dart index 83f5f02..9f6140f 100644 --- a/lib/data/immich/immich_service.dart +++ b/lib/data/immich/immich_service.dart @@ -469,6 +469,71 @@ class ImmichService { return '$_baseUrl/api/assets/$assetId/original'; } + /// Creates a shared link for an asset (public URL). + /// + /// [assetId] - The unique identifier of the asset. + /// + /// Returns the public shared link URL, or null if creation fails. + /// + /// Note: This creates a shared link that can be accessed without authentication. + /// Immich shared links are typically for albums, but we can create one with a single asset. + Future createSharedLinkForAsset(String assetId) async { + try { + // First, try to create an album with the asset, then create a shared link + // Or create a shared link directly if the API supports it + // POST /api/shared-links + // According to Immich API, shared links can be created with assetIds + final response = await _dio.post( + '/api/shared-links', + data: { + 'type': 'ALBUM', + 'assetIds': [assetId], + 'description': 'Profile picture', + }, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + final data = response.data as Map; + final shareId = data['id'] as String?; + if (shareId != null) { + // Return the public shared link URL + // Format: {baseUrl}/share/{shareId} + // Note: The actual format might be different - this is the typical Immich shared link format + final baseUrlWithoutApi = _baseUrl.replaceAll('/api', '').replaceAll(RegExp(r'/$'), ''); + return '$baseUrlWithoutApi/share/$shareId'; + } + } + return null; + } catch (e) { + Logger.warning('Failed to create shared link for asset: $e'); + // If shared link creation fails, we'll fall back to authenticated URL + return null; + } + } + + /// Gets or creates a public shared link for an asset. + /// + /// This method attempts to create a shared link if one doesn't exist. + /// The shared link provides a public URL that doesn't require authentication. + /// + /// [assetId] - The unique identifier of the asset. + /// + /// Returns the public shared link URL, or the authenticated URL as fallback. + Future getPublicUrlForAsset(String assetId) async { + try { + // Try to create a shared link + final sharedLink = await createSharedLinkForAsset(assetId); + if (sharedLink != null) { + return sharedLink; + } + } catch (e) { + Logger.warning('Failed to get public URL, using authenticated URL: $e'); + } + + // Fallback to authenticated URL if shared link creation fails + return getImageUrl(assetId); + } + /// Gets the base URL for Immich API. String get baseUrl => _baseUrl; diff --git a/lib/data/recipes/recipe_service.dart b/lib/data/recipes/recipe_service.dart index 6d215ae..26a0861 100644 --- a/lib/data/recipes/recipe_service.dart +++ b/lib/data/recipes/recipe_service.dart @@ -32,6 +32,10 @@ class RecipeService { /// Optional database path for testing (null uses default). final String? _testDbPath; + /// Pending bookmark associations to restore after recipes are fetched from Nostr. + /// Map of recipeId -> list of categoryIds. + final Map> _pendingBookmarks = {}; + /// Creates a [RecipeService] instance. RecipeService({ required LocalStorageService localStorage, @@ -558,6 +562,17 @@ class RecipeService { ); Logger.info('Bookmark category created: ${category.id}'); + + // Publish to Nostr (kind 30001 for bookmark categories) + if (_nostrService != null && _nostrKeyPair != null) { + try { + await _publishBookmarkCategoryToNostr(category); + Logger.info('Bookmark category ${category.id} published to Nostr'); + } catch (e) { + Logger.warning('Failed to publish bookmark category to Nostr: $e'); + } + } + return category; } catch (e) { throw Exception('Failed to create bookmark category: $e'); @@ -621,6 +636,16 @@ class RecipeService { whereArgs: [category.id], ); Logger.info('Bookmark category updated: ${category.id}'); + + // Publish to Nostr (kind 30001 for bookmark categories) + if (_nostrService != null && _nostrKeyPair != null) { + try { + await _publishBookmarkCategoryToNostr(updatedCategory); + Logger.info('Bookmark category ${category.id} updated in Nostr'); + } catch (e) { + Logger.warning('Failed to publish bookmark category update to Nostr: $e'); + } + } } catch (e) { throw Exception('Failed to update bookmark category: $e'); } @@ -648,6 +673,17 @@ class RecipeService { conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.info('Recipe $recipeId added to category $categoryId'); + + // Republish recipe to Nostr to sync bookmark changes + try { + final recipe = await getRecipe(recipeId); + if (recipe != null && _nostrService != null && _nostrKeyPair != null) { + await _publishRecipeToNostr(recipe); + Logger.debug('Republished recipe $recipeId to Nostr with updated bookmarks'); + } + } catch (e) { + Logger.warning('Failed to republish recipe $recipeId after bookmark change: $e'); + } } catch (e) { throw Exception('Failed to add recipe to category: $e'); } @@ -671,6 +707,17 @@ class RecipeService { whereArgs: [recipeId, categoryId], ); Logger.info('Recipe $recipeId removed from category $categoryId'); + + // Republish recipe to Nostr to sync bookmark changes + try { + final recipe = await getRecipe(recipeId); + if (recipe != null && _nostrService != null && _nostrKeyPair != null) { + await _publishRecipeToNostr(recipe); + Logger.debug('Republished recipe $recipeId to Nostr with updated bookmarks'); + } + } catch (e) { + Logger.warning('Failed to republish recipe $recipeId after bookmark change: $e'); + } } catch (e) { throw Exception('Failed to remove recipe from category: $e'); } @@ -768,6 +815,14 @@ class RecipeService { // Ensure database is initialized before fetching await _ensureInitializedOrReinitialize(); + // First, fetch bookmark categories so they exist when we restore bookmark associations + try { + await fetchBookmarkCategoriesFromNostr(publicKey, timeout: timeout); + } catch (e) { + Logger.warning('Failed to fetch bookmark categories, continuing with recipes: $e'); + // Continue even if category fetch fails + } + try { Logger.info('Fetching recipes from Nostr for public key: ${publicKey.substring(0, 16)}...'); @@ -801,17 +856,28 @@ class RecipeService { } try { - final relayRecipes = await _fetchRecipesFromRelay( + final relayData = await _queryRecipeEventsFromRelayWithBookmarks( publicKey, relay.url, timeout, ); - // Add recipes, avoiding duplicates (by recipe ID from 'd' tag) - for (final recipe in relayRecipes) { - if (!seenRecipeIds.contains(recipe.id)) { + // Add recipes and bookmark data, avoiding duplicates + for (final entry in relayData.entries) { + final recipeId = entry.key; + final (recipe, bookmarkCategoryIds) = entry.value; + + if (!seenRecipeIds.contains(recipeId)) { recipes.add(recipe); - seenRecipeIds.add(recipe.id); + seenRecipeIds.add(recipeId); + + // Store bookmark associations for later restoration + if (bookmarkCategoryIds.isNotEmpty) { + if (!_pendingBookmarks.containsKey(recipeId)) { + _pendingBookmarks[recipeId] = []; + } + _pendingBookmarks[recipeId]!.addAll(bookmarkCategoryIds); + } } } } catch (e) { @@ -846,7 +912,68 @@ class RecipeService { } } + // Restore bookmark associations from Nostr events + int bookmarkCount = 0; + for (final entry in _pendingBookmarks.entries) { + final recipeId = entry.key; + final categoryIds = entry.value; + + try { + // Verify recipe exists + final recipe = await getRecipe(recipeId); + if (recipe == null) { + Logger.warning('Cannot restore bookmarks for recipe $recipeId: recipe not found'); + continue; + } + + // Restore bookmark associations + for (final categoryId in categoryIds) { + try { + // Check if category exists, create if it doesn't (with a default name) + final category = await _db!.query( + 'bookmark_categories', + where: 'id = ?', + whereArgs: [categoryId], + limit: 1, + ); + + if (category.isEmpty) { + // Category should have been fetched from Nostr, but if it doesn't exist, + // create a default category as fallback + Logger.warning('Bookmark category $categoryId not found, creating default'); + await _db!.insert( + 'bookmark_categories', + { + 'id': categoryId, + 'name': 'Category $categoryId', + 'color': null, + 'created_at': DateTime.now().millisecondsSinceEpoch, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + Logger.debug('Created default bookmark category: $categoryId'); + } + + // Add recipe to category + await addRecipeToCategory(recipeId: recipeId, categoryId: categoryId); + bookmarkCount++; + } catch (e) { + Logger.warning('Failed to restore bookmark $categoryId for recipe $recipeId: $e'); + } + } + } catch (e) { + Logger.warning('Failed to restore bookmarks for recipe $recipeId: $e'); + } + } + + // Clear pending bookmarks after processing + _pendingBookmarks.clear(); + Logger.info('Fetched and stored $storedCount recipe(s) from Nostr'); + if (bookmarkCount > 0) { + Logger.info('Restored $bookmarkCount bookmark association(s) from Nostr'); + } return storedCount; } catch (e) { Logger.error('Failed to fetch recipes from Nostr', e); @@ -868,6 +995,120 @@ class RecipeService { return await _queryRecipeEventsFromRelay(publicKey, relayUrl, timeout); } + /// Queries recipe events from a relay and returns recipes with bookmark data. + /// Returns a map where key is recipe ID and value is a tuple of (recipe, bookmarkCategoryIds). + Future)>> _queryRecipeEventsFromRelayWithBookmarks( + String publicKey, + String relayUrl, + Duration timeout, + ) async { + try { + // Use NostrService's queryEvents method to get kind 30000 events + final events = await _nostrService!.queryEvents( + publicKey, + relayUrl, + [30000], + timeout: timeout, + ); + + final Map recipeMap = {}; // Track by 'd' tag value + final Map> recipeBookmarks = {}; // Track bookmark associations: recipeId -> [categoryIds] + + for (final event in events) { + try { + // Extract recipe ID from 'd' tag + String? recipeId; + for (final tag in event.tags) { + if (tag.isNotEmpty && tag[0] == 'd') { + recipeId = tag.length > 1 ? tag[1] : null; + break; + } + } + + if (recipeId == null) { + Logger.warning('Recipe event missing "d" tag: ${event.id}'); + continue; + } + + // Parse content as JSON + final contentMap = jsonDecode(event.content) as Map; + + // Extract additional metadata from tags + final imageUrls = []; + final tags = []; + final bookmarkCategoryIds = []; + int rating = 0; + bool isFavourite = false; + + for (final tag in event.tags) { + if (tag.isEmpty) continue; + switch (tag[0]) { + case 'image': + if (tag.length > 1) imageUrls.add(tag[1]); + break; + case 't': + if (tag.length > 1) tags.add(tag[1]); + break; + case 'rating': + if (tag.length > 1) { + rating = int.tryParse(tag[1]) ?? 0; + } + break; + case 'favourite': + if (tag.length > 1) { + isFavourite = tag[1].toLowerCase() == 'true'; + } + break; + case 'bookmark': + if (tag.length > 1) { + bookmarkCategoryIds.add(tag[1]); + } + break; + } + } + + // Create RecipeModel from event + final recipe = RecipeModel( + id: recipeId, + title: contentMap['title'] as String? ?? 'Untitled Recipe', + description: contentMap['description'] as String?, + tags: tags.isNotEmpty ? tags : (contentMap['tags'] as List?)?.map((e) => e.toString()).toList() ?? [], + rating: rating > 0 ? rating : (contentMap['rating'] as int? ?? 0), + isFavourite: isFavourite || (contentMap['isFavourite'] as bool? ?? false), + imageUrls: imageUrls.isNotEmpty ? imageUrls : (contentMap['imageUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], + createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000), + updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000), + nostrEventId: event.id, + ); + + // Use replaceable event logic: keep the latest version + final existing = recipeMap[recipeId]; + if (existing == null || recipe.updatedAt > existing.updatedAt) { + recipeMap[recipeId] = recipe; + + // Store bookmark associations + if (bookmarkCategoryIds.isNotEmpty) { + recipeBookmarks[recipeId] = bookmarkCategoryIds; + } + } + } catch (e) { + Logger.warning('Failed to parse recipe from event ${event.id}: $e'); + } + } + + // Return map of recipeId -> (recipe, bookmarkCategoryIds) + final result = )>{}; + for (final entry in recipeMap.entries) { + result[entry.key] = (entry.value, recipeBookmarks[entry.key] ?? []); + } + + return result; + } catch (e) { + Logger.error('Failed to query recipes from relay $relayUrl', e); + rethrow; + } + } + /// Queries recipe events from a relay using NostrService's queryEvents method. Future> _queryRecipeEventsFromRelay( String publicKey, @@ -884,6 +1125,7 @@ class RecipeService { ); final Map recipeMap = {}; // Track by 'd' tag value + final Map> recipeBookmarks = {}; // Track bookmark associations: recipeId -> [categoryIds] for (final event in events) { try { @@ -907,6 +1149,7 @@ class RecipeService { // Extract additional metadata from tags final imageUrls = []; final tags = []; + final bookmarkCategoryIds = []; int rating = 0; bool isFavourite = false; @@ -929,6 +1172,11 @@ class RecipeService { isFavourite = tag[1].toLowerCase() == 'true'; } break; + case 'bookmark': + if (tag.length > 1) { + bookmarkCategoryIds.add(tag[1]); + } + break; } } @@ -950,13 +1198,33 @@ class RecipeService { final existing = recipeMap[recipeId]; if (existing == null || recipe.updatedAt > existing.updatedAt) { recipeMap[recipeId] = recipe; + + // Store bookmark associations for later processing + if (bookmarkCategoryIds.isNotEmpty) { + recipeBookmarks[recipeId] = bookmarkCategoryIds; + } } } catch (e) { Logger.warning('Failed to parse recipe from event ${event.id}: $e'); } } - return recipeMap.values.toList(); + final recipes = recipeMap.values.toList(); + + // Store bookmark associations in the database for each recipe + // Note: Recipes will be stored in fetchRecipesFromNostr, but we can prepare bookmark data here + for (final entry in recipeBookmarks.entries) { + final recipeId = entry.key; + final categoryIds = entry.value; + + // Check if recipe exists in database (it should after fetchRecipesFromNostr stores it) + // We'll restore bookmarks in fetchRecipesFromNostr after recipes are stored + // For now, attach bookmark data to recipes as metadata + // Actually, we need to return bookmark data separately + } + + // Return recipes - bookmark restoration will happen in fetchRecipesFromNostr + return recipes; } catch (e) { Logger.error('Failed to query recipes from relay $relayUrl', e); rethrow; @@ -991,6 +1259,16 @@ class RecipeService { // Add favourite tag tags.add(['favourite', recipe.isFavourite.toString()]); + // Add bookmark category tags + try { + final bookmarkCategories = await getCategoriesForRecipe(recipe.id); + for (final category in bookmarkCategories) { + tags.add(['bookmark', category.id]); + } + } catch (e) { + Logger.warning('Failed to get bookmark categories for recipe ${recipe.id}: $e'); + } + // Create event content as JSON final content = recipe.toJson(); @@ -1024,6 +1302,184 @@ class RecipeService { } } + /// Publishes a bookmark category to Nostr as a kind 30001 event. + /// + /// [category] - The bookmark category to publish. + Future _publishBookmarkCategoryToNostr(BookmarkCategory category) async { + if (_nostrService == null || _nostrKeyPair == null) { + return; + } + + try { + // Create tags - use category ID as the 'd' tag for replaceable events + final tags = >[ + ['d', category.id], // Replaceable event identifier + ]; + + // Create event content as JSON + final content = category.toJson(); + + // Create and sign the event + // Using kind 30001 for bookmark categories (NIP-33 parameterized replaceable event) + final event = NostrEvent.create( + content: jsonEncode(content), + kind: 30001, + privateKey: _nostrKeyPair!.privateKey, + tags: tags, + ); + + // Publish to all enabled relays + final results = await _nostrService!.publishEventToAllRelays(event); + + // Log results + final successCount = results.values.where((success) => success).length; + Logger.info('Published bookmark category ${category.id} to $successCount/${results.length} relays'); + } catch (e) { + Logger.error('Failed to publish bookmark category to Nostr', e); + rethrow; + } + } + + /// Fetches bookmark categories from Nostr for a given public key. + /// + /// Queries kind 30001 events (bookmark categories) from enabled relays and stores them locally. + /// + /// [publicKey] - The public key (hex format) to fetch categories for. + /// [timeout] - Timeout for the request (default: 30 seconds). + /// + /// Returns the number of categories fetched and stored. + /// + /// Throws [Exception] if fetch fails. + Future fetchBookmarkCategoriesFromNostr( + String publicKey, { + Duration timeout = const Duration(seconds: 30), + }) async { + if (_nostrService == null) { + throw Exception('NostrService not available'); + } + + await _ensureInitializedOrReinitialize(); + + try { + final relays = _nostrService!.getRelays(); + final enabledRelays = relays.where((r) => r.isEnabled && r.isConnected).toList(); + + if (enabledRelays.isEmpty) { + Logger.warning('No enabled and connected relays available for fetching bookmark categories'); + return 0; + } + + final Map categoryMap = {}; // Track by category ID + + // Fetch from all enabled relays + for (final relay in enabledRelays) { + try { + final relayCategories = await _queryBookmarkCategoriesFromRelay( + publicKey, + relay.url, + timeout, + ); + + // Add categories, keeping the latest version (by updatedAt) + for (final category in relayCategories) { + final existing = categoryMap[category.id]; + if (existing == null || category.updatedAt > existing.updatedAt) { + categoryMap[category.id] = category; + } + } + } catch (e) { + Logger.warning('Failed to fetch bookmark categories from relay ${relay.url}: $e'); + // Continue to next relay + } + } + + // Store fetched categories in database + int storedCount = 0; + for (final category in categoryMap.values) { + try { + await _db!.insert( + 'bookmark_categories', + category.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + Logger.debug('Stored bookmark category ${category.id} (${category.name}) in database'); + storedCount++; + } catch (e) { + Logger.warning('Failed to store bookmark category ${category.id}: $e'); + // Continue with other categories + } + } + + Logger.info('Fetched and stored $storedCount bookmark category(ies) from Nostr'); + return storedCount; + } catch (e) { + Logger.error('Failed to fetch bookmark categories from Nostr', e); + throw Exception('Failed to fetch bookmark categories from Nostr: $e'); + } + } + + /// Queries bookmark category events from a relay using NostrService's queryEvents method. + Future> _queryBookmarkCategoriesFromRelay( + String publicKey, + String relayUrl, + Duration timeout, + ) async { + try { + // Use NostrService's queryEvents method to get kind 30001 events + final events = await _nostrService!.queryEvents( + publicKey, + relayUrl, + [30001], + timeout: timeout, + ); + + final Map categoryMap = {}; // Track by 'd' tag value + + for (final event in events) { + try { + // Extract category ID from 'd' tag + String? categoryId; + for (final tag in event.tags) { + if (tag.isNotEmpty && tag[0] == 'd') { + categoryId = tag.length > 1 ? tag[1] : null; + break; + } + } + + if (categoryId == null) { + Logger.warning('Bookmark category event missing "d" tag: ${event.id}'); + continue; + } + + // Parse content as JSON + final contentMap = jsonDecode(event.content) as Map; + + // Create BookmarkCategory from event + final category = BookmarkCategory( + id: categoryId, + name: contentMap['name'] as String? ?? 'Unnamed Category', + color: contentMap['color'] as String?, + createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000), + updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000), + ); + + // Use replaceable event logic: keep the latest version + final existing = categoryMap[categoryId]; + if (existing == null || category.updatedAt > existing.updatedAt) { + categoryMap[categoryId] = category; + } + } catch (e) { + Logger.warning('Failed to parse bookmark category from event ${event.id}: $e'); + } + } + + return categoryMap.values.toList(); + } catch (e) { + Logger.error('Failed to query bookmark categories from relay $relayUrl', e); + rethrow; + } + } + /// Publishes a recipe deletion to Nostr as a kind 5 event. /// /// [recipeId] - The ID of the recipe being deleted. diff --git a/lib/data/session/session_service.dart b/lib/data/session/session_service.dart index 7ec4625..22084ff 100644 --- a/lib/data/session/session_service.dart +++ b/lib/data/session/session_service.dart @@ -216,6 +216,41 @@ class SessionService { } } + /// Updates the current user's profile. + /// + /// [updatedUser] - The user with updated profile information. + /// + /// Throws [SessionException] if update fails or if no user is logged in. + Future updateUserProfile(User updatedUser) async { + if (_currentUser == null) { + throw SessionException('No user logged in.'); + } + + if (_currentUser!.id != updatedUser.id) { + throw SessionException('Cannot update different user profile.'); + } + + try { + // Update current user + _currentUser = updatedUser; + + // Optionally refresh profile from Nostr to ensure consistency + if (updatedUser.nostrProfile != null && _nostrService != null) { + try { + final refreshedProfile = await _nostrService!.fetchProfile(updatedUser.id); + if (refreshedProfile != null) { + _currentUser = updatedUser.copyWith(nostrProfile: refreshedProfile); + } + } catch (e) { + // Log but don't fail - offline-first behavior + Logger.warning('Failed to refresh profile from Nostr: $e'); + } + } + } catch (e) { + throw SessionException('Failed to update user profile: $e'); + } + } + /// Logs out the current user and clears session data. /// /// [clearCache] - Whether to clear cached data (default: true). diff --git a/lib/ui/session/session_screen.dart b/lib/ui/session/session_screen.dart index bca7938..ad7b92e 100644 --- a/lib/ui/session/session_screen.dart +++ b/lib/ui/session/session_screen.dart @@ -1,9 +1,12 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../../core/logger.dart'; import '../../core/service_locator.dart'; import '../../data/nostr/nostr_service.dart'; import '../../data/nostr/models/nostr_keypair.dart'; +import '../../data/session/models/user.dart'; import '../navigation/app_router.dart'; +import '../user_edit/user_edit_screen.dart'; /// Screen for user session management (login/logout). class SessionScreen extends StatefulWidget { @@ -262,6 +265,76 @@ class _SessionScreenState extends State { } } + /// Builds a profile picture widget using ImmichService for authenticated access. + Widget _buildProfilePicture(String? imageUrl, {double radius = 30}) { + if (imageUrl == null) { + return CircleAvatar( + radius: radius, + child: const Icon(Icons.person), + ); + } + + final immichService = ServiceLocator.instance.immichService; + + // Try to extract asset ID from URL (format: .../api/assets/{id}/original) + final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); + + if (assetIdMatch != null && immichService != null) { + final assetId = assetIdMatch.group(1); + if (assetId != null) { + // Use ImmichService to fetch image with proper authentication + return FutureBuilder( + future: immichService.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) { + return CircleAvatar( + radius: radius, + backgroundColor: Colors.grey[300], + child: const Icon(Icons.broken_image), + ); + } + + return CircleAvatar( + radius: radius, + backgroundImage: MemoryImage(snapshot.data!), + ); + }, + ); + } + } + + // Fallback to direct network image if not an Immich URL or service unavailable + return CircleAvatar( + radius: radius, + backgroundImage: NetworkImage(imageUrl), + onBackgroundImageError: (_, __) {}, + ); + } + + Future _editProfile(BuildContext context, User user) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => UserEditScreen(user: user), + ), + ); + + // If profile was updated, refresh the screen + if (result == true && mounted) { + setState(() {}); + // Also trigger the session changed callback to update parent + widget.onSessionChanged?.call(); + } + } + Future _handleRefresh() async { if (ServiceLocator.instance.sessionService == null) return; @@ -338,37 +411,44 @@ class _SessionScreenState extends State { ), ), const SizedBox(height: 12), - // Display Nostr profile if available - if (currentUser.nostrProfile != null) ...[ + // Display Nostr profile if available, or show edit option if has private key + if (currentUser.nostrProfile != null || currentUser.nostrPrivateKey != null) ...[ Row( children: [ - if (currentUser.nostrProfile!.picture != null) - CircleAvatar( - radius: 30, - backgroundImage: NetworkImage( - currentUser.nostrProfile!.picture!, - ), - onBackgroundImageError: (_, __) {}, - ) - else - const CircleAvatar( - radius: 30, - child: Icon(Icons.person), - ), + _buildProfilePicture( + currentUser.nostrProfile?.picture, + radius: 30, + ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - currentUser.nostrProfile!.name ?? - currentUser.nostrProfile!.displayName, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + Row( + children: [ + Expanded( + child: Text( + currentUser.nostrProfile?.name ?? + currentUser.nostrProfile?.displayName ?? + currentUser.username, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + // Show edit icon if user has private key (can publish updates) + if (currentUser.nostrPrivateKey != null) + IconButton( + icon: const Icon(Icons.edit, size: 20), + onPressed: () => _editProfile(context, currentUser), + tooltip: 'Edit Profile', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], ), - if (currentUser.nostrProfile!.about != null) + if (currentUser.nostrProfile?.about != null) Text( currentUser.nostrProfile!.about!, style: TextStyle( diff --git a/lib/ui/user_edit/user_edit_screen.dart b/lib/ui/user_edit/user_edit_screen.dart new file mode 100644 index 0000000..aeee323 --- /dev/null +++ b/lib/ui/user_edit/user_edit_screen.dart @@ -0,0 +1,479 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../core/service_locator.dart'; +import '../../core/logger.dart'; +import '../../data/session/models/user.dart'; +import '../../data/nostr/models/nostr_profile.dart'; +import '../../data/nostr/nostr_service.dart'; +import '../../data/nostr/models/nostr_keypair.dart'; +import '../../data/immich/immich_service.dart'; + +/// Screen for editing user profile information. +class UserEditScreen extends StatefulWidget { + final User user; + + const UserEditScreen({ + super.key, + required this.user, + }); + + @override + State createState() => _UserEditScreenState(); +} + +class _UserEditScreenState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _websiteController = TextEditingController(); + final _lightningController = TextEditingController(); + final _nip05Controller = TextEditingController(); + + String? _profilePictureUrl; + File? _selectedImageFile; + Uint8List? _selectedImageBytes; + bool _isUploading = false; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadProfileData(); + } + + @override + void dispose() { + _nameController.dispose(); + _websiteController.dispose(); + _lightningController.dispose(); + _nip05Controller.dispose(); + super.dispose(); + } + + void _loadProfileData() { + final profile = widget.user.nostrProfile; + if (profile != null) { + _nameController.text = profile.name ?? ''; + _websiteController.text = profile.website ?? ''; + _lightningController.text = profile.lud16 ?? ''; + _nip05Controller.text = profile.nip05 ?? ''; + _profilePictureUrl = profile.picture; + } + } + + Future _pickProfilePicture() async { + final picker = ImagePicker(); + + // Show dialog to choose source + final source = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Profile Picture'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Take Photo'), + onTap: () => Navigator.pop(context, ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Choose from Gallery'), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ], + ), + ), + ); + + if (source == null) return; + + try { + final pickedFile = await picker.pickImage( + source: source, + imageQuality: 85, + maxWidth: 800, + maxHeight: 800, + ); + + if (pickedFile != null) { + setState(() { + _selectedImageFile = File(pickedFile.path); + _selectedImageBytes = null; // Will be loaded when uploading + }); + + // Automatically upload the image + await _uploadProfilePicture(); + } + } catch (e) { + Logger.error('Failed to pick image', e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to pick image: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _uploadProfilePicture() async { + if (_selectedImageFile == null) return; + + final immichService = ServiceLocator.instance.immichService; + if (immichService == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Immich service not available'), + backgroundColor: Colors.orange, + ), + ); + } + return; + } + + setState(() { + _isUploading = true; + }); + + try { + final uploadResponse = await immichService.uploadImage(_selectedImageFile!); + + // Try to get a public shared link URL for the uploaded image + // This will be used in the Nostr profile so it can be accessed without authentication + String imageUrl; + try { + final publicUrl = await immichService.getPublicUrlForAsset(uploadResponse.id); + imageUrl = publicUrl; + Logger.info('Using public URL for profile picture: $imageUrl'); + } catch (e) { + // Fallback to authenticated URL if public URL creation fails + imageUrl = immichService.getImageUrl(uploadResponse.id); + Logger.warning('Failed to create public URL, using authenticated URL: $e'); + } + + setState(() { + _profilePictureUrl = imageUrl; + _selectedImageBytes = null; + _isUploading = false; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Profile picture uploaded successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + } + } catch (e) { + Logger.error('Failed to upload profile picture', e); + setState(() { + _isUploading = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to upload profile picture: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _saveProfile() async { + if (!_formKey.currentState!.validate()) return; + + if (_isSaving) return; + + setState(() { + _isSaving = true; + }); + + try { + final sessionService = ServiceLocator.instance.sessionService; + final nostrService = ServiceLocator.instance.nostrService; + + if (sessionService == null || nostrService == null) { + throw Exception('Session or Nostr service not available'); + } + + // Get current user + final currentUser = sessionService.currentUser; + if (currentUser == null) { + throw Exception('No user logged in'); + } + + // Check if user has private key (required for publishing) + if (currentUser.nostrPrivateKey == null) { + throw Exception('Private key not available. Cannot update profile.'); + } + + // Create updated profile (or new one if doesn't exist) + final updatedProfile = NostrProfile( + publicKey: currentUser.id, + name: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(), + website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(), + lud16: _lightningController.text.trim().isEmpty ? null : _lightningController.text.trim(), + nip05: _nip05Controller.text.trim().isEmpty ? null : _nip05Controller.text.trim(), + picture: _profilePictureUrl, + about: currentUser.nostrProfile?.about, // Preserve existing about if any + banner: currentUser.nostrProfile?.banner, // Preserve existing banner if any + rawMetadata: currentUser.nostrProfile?.rawMetadata ?? {}, + updatedAt: DateTime.now(), + ); + + // Convert profile to metadata map for Nostr + final metadata = {}; + if (updatedProfile.name != null) metadata['name'] = updatedProfile.name; + if (updatedProfile.picture != null) metadata['picture'] = updatedProfile.picture; + if (updatedProfile.website != null) metadata['website'] = updatedProfile.website; + if (updatedProfile.lud16 != null) metadata['lud16'] = updatedProfile.lud16; + if (updatedProfile.nip05 != null) metadata['nip05'] = updatedProfile.nip05; + if (updatedProfile.about != null) metadata['about'] = updatedProfile.about; + if (updatedProfile.banner != null) metadata['banner'] = updatedProfile.banner; + + // Publish to Nostr (kind 0 event - profile metadata) + Logger.info('Publishing profile update to Nostr (kind 0)...'); + final keyPair = NostrKeyPair.fromNsec(currentUser.nostrPrivateKey!); + final publishedEvent = await nostrService.syncMetadata( + metadata: metadata, + privateKey: keyPair.privateKey, + kind: 0, // Kind 0 is for profile metadata + ); + Logger.info('Profile published to Nostr: ${publishedEvent.id}'); + + // Update user in session service + final updatedUser = currentUser.copyWith( + nostrProfile: updatedProfile, + username: updatedProfile.name ?? currentUser.username, + ); + + // Update session service (we'll need to add a method for this) + await sessionService.updateUserProfile(updatedUser); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Profile updated successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + Navigator.of(context).pop(true); // Return true to indicate success + } + } catch (e) { + Logger.error('Failed to save profile', e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save profile: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + /// Builds a profile picture widget using ImmichService for authenticated access. + Widget _buildAuthenticatedProfilePicture(String? imageUrl, {double radius = 60}) { + if (imageUrl == null) { + return CircleAvatar( + radius: radius, + backgroundColor: Colors.grey[300], + child: Icon(Icons.person, size: radius), + ); + } + + final immichService = ServiceLocator.instance.immichService; + + // Try to extract asset ID from URL (format: .../api/assets/{id}/original) + final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); + + if (assetIdMatch != null && immichService != null) { + final assetId = assetIdMatch.group(1); + if (assetId != null) { + // Use ImmichService to fetch image with proper authentication + return FutureBuilder( + future: immichService.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) { + return CircleAvatar( + radius: radius, + backgroundColor: Colors.grey[300], + child: const Icon(Icons.broken_image), + ); + } + + return CircleAvatar( + radius: radius, + backgroundImage: MemoryImage(snapshot.data!), + ); + }, + ); + } + } + + // Fallback to direct network image if not an Immich URL or service unavailable + return CircleAvatar( + radius: radius, + backgroundColor: Colors.grey[300], + backgroundImage: NetworkImage(imageUrl), + onBackgroundImageError: (_, __) {}, + child: imageUrl == null ? Icon(Icons.person, size: radius) : null, + ); + } + + Widget _buildProfilePicture() { + return Center( + child: Stack( + children: [ + _buildAuthenticatedProfilePicture(_profilePictureUrl, radius: 60), + if (_isUploading) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + ), + child: const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon(Icons.camera_alt, color: Colors.white), + onPressed: _isUploading ? null : _pickProfilePicture, + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Edit Profile'), + actions: [ + if (_isSaving) + const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else + IconButton( + icon: const Icon(Icons.save), + onPressed: _saveProfile, + tooltip: 'Save', + ), + ], + ), + body: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + _buildProfilePicture(), + const SizedBox(height: 32), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + hintText: 'Your display name', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _websiteController, + decoration: const InputDecoration( + labelText: 'Website', + hintText: 'https://example.com', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.language), + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + TextFormField( + controller: _lightningController, + decoration: const InputDecoration( + labelText: 'Lightning Address', + hintText: 'yourname@domain.com', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.bolt), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _nip05Controller, + decoration: const InputDecoration( + labelText: 'NIP-05 ID', + hintText: 'yourname@domain.com', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.verified), + ), + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _isSaving ? null : _saveProfile, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save Profile'), + ), + ], + ), + ), + ), + ); + } +} +