bookmarks feature fix

master
gitea 2 months ago
parent 72aedb6c40
commit 52c449356a

@ -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<String?> 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<String, dynamic>;
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<String> 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;

@ -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<String, List<String>> _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<Map<String, (RecipeModel, List<String>)>> _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<String, RecipeModel> recipeMap = {}; // Track by 'd' tag value
final Map<String, List<String>> 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<String, dynamic>;
// Extract additional metadata from tags
final imageUrls = <String>[];
final tags = <String>[];
final bookmarkCategoryIds = <String>[];
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<dynamic>?)?.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<dynamic>?)?.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 = <String, (RecipeModel, List<String>)>{};
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<List<RecipeModel>> _queryRecipeEventsFromRelay(
String publicKey,
@ -884,6 +1125,7 @@ class RecipeService {
);
final Map<String, RecipeModel> recipeMap = {}; // Track by 'd' tag value
final Map<String, List<String>> 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 = <String>[];
final tags = <String>[];
final bookmarkCategoryIds = <String>[];
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<void> _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 = <List<String>>[
['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<int> 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<String, BookmarkCategory> 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<List<BookmarkCategory>> _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<String, BookmarkCategory> 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<String, dynamic>;
// 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.

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

@ -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<SessionScreen> {
}
}
/// 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<Uint8List?>(
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<void> _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<void> _handleRefresh() async {
if (ServiceLocator.instance.sessionService == null) return;
@ -338,37 +411,44 @@ class _SessionScreenState extends State<SessionScreen> {
),
),
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(

@ -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<UserEditScreen> createState() => _UserEditScreenState();
}
class _UserEditScreenState extends State<UserEditScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _pickProfilePicture() async {
final picker = ImagePicker();
// Show dialog to choose source
final source = await showDialog<ImageSource>(
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<void> _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<void> _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 = <String, dynamic>{};
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<Uint8List?>(
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'),
),
],
),
),
),
);
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.