blossom media server support added

master
gitea 2 months ago
parent 52c449356a
commit 72d2469dce

@ -9,11 +9,19 @@
# - pubspec.yaml (name field - used for package name)
APP_NAME=app_boilerplate
# Immich Enable/Disable
# Set to 'false' to disable Immich and only use Blossom media server
# Default: true
IMMICH_ENABLE=true
# Immich Configuration
IMMICH_BASE_URL=https://photos.satoshinakamoto.win
IMMICH_API_KEY_DEV=your-dev-api-key-here
IMMICH_API_KEY_PROD=your-prod-api-key-here
# Blossom Media Server Configuration
# Default Blossom server URL (used when Immich is disabled or as default)
BLOSSOM_SERVER=https://media.based21.com
# Nostr Relays (comma-separated list)
NOSTR_RELAYS_DEV=wss://nostrum.satoshinakamoto.win,wss://nos.lol
NOSTR_RELAYS_PROD=wss://relay.damus.io

@ -373,6 +373,15 @@ flutter test test/ui/navigation/main_navigation_scaffold_test.dart
IMMICH_API_KEY_DEV=your-dev-api-key-here
IMMICH_API_KEY_PROD=your-prod-api-key-here
# Immich Enable/Disable
# Set to 'false' to disable Immich and only use Blossom media server
# Default: true
IMMICH_ENABLE=true
# Blossom Media Server Configuration
# Default Blossom server URL (used when Immich is disabled or as default)
BLOSSOM_SERVER=https://media.based21.com
# Nostr Relays (comma-separated list)
NOSTR_RELAYS_DEV=wss://nostrum.satoshinakamoto.win,wss://nos.lol
NOSTR_RELAYS_PROD=wss://relay.damus.io

@ -17,6 +17,12 @@ class AppConfig {
/// Immich API key for authentication.
final String immichApiKey;
/// Whether Immich is enabled (if false, only Blossom will be available).
final bool immichEnabled;
/// Blossom server base URL (e.g., 'https://media.based21.com').
final String blossomServer;
/// List of Nostr relay URLs for testing and production.
final List<String> nostrRelays;
@ -29,6 +35,8 @@ class AppConfig {
/// [enableLogging] - Whether logging should be enabled.
/// [immichBaseUrl] - Immich server base URL.
/// [immichApiKey] - Immich API key for authentication.
/// [immichEnabled] - Whether Immich is enabled (if false, only Blossom will be available).
/// [blossomServer] - Blossom server base URL.
/// [nostrRelays] - List of Nostr relay URLs (e.g., ['wss://relay.example.com']).
/// [firebaseConfig] - Firebase configuration for this environment.
const AppConfig({
@ -36,6 +44,8 @@ class AppConfig {
required this.enableLogging,
required this.immichBaseUrl,
required this.immichApiKey,
required this.immichEnabled,
required this.blossomServer,
required this.nostrRelays,
required this.firebaseConfig,
});

@ -78,6 +78,8 @@ class ConfigLoader {
enableLogging: getBoolEnv('ENABLE_LOGGING_DEV', true),
immichBaseUrl: getEnv('IMMICH_BASE_URL', 'https://photos.satoshinakamoto.win'),
immichApiKey: getEnv('IMMICH_API_KEY_DEV', 'your-dev-api-key-here'),
immichEnabled: getBoolEnv('IMMICH_ENABLE', true),
blossomServer: getEnv('BLOSSOM_SERVER', 'https://media.based21.com'),
nostrRelays: getListEnv('NOSTR_RELAYS_DEV', [
'wss://nostrum.satoshinakamoto.win',
'wss://nos.lol',
@ -90,6 +92,8 @@ class ConfigLoader {
enableLogging: getBoolEnv('ENABLE_LOGGING_PROD', false),
immichBaseUrl: getEnv('IMMICH_BASE_URL', 'https://photos.satoshinakamoto.win'),
immichApiKey: getEnv('IMMICH_API_KEY_PROD', 'your-prod-api-key-here'),
immichEnabled: getBoolEnv('IMMICH_ENABLE', true),
blossomServer: getEnv('BLOSSOM_SERVER', 'https://media.based21.com'),
nostrRelays: getListEnv('NOSTR_RELAYS_PROD', [
'wss://relay.damus.io',
]),

@ -7,6 +7,8 @@ import '../data/sync/sync_engine.dart';
import '../data/firebase/firebase_service.dart';
import '../data/session/session_service.dart';
import '../data/immich/immich_service.dart';
import '../data/blossom/blossom_service.dart';
import '../data/media/media_service_interface.dart';
import '../data/recipes/recipe_service.dart';
import 'app_services.dart';
import 'service_locator.dart';
@ -96,14 +98,46 @@ class AppInitializer {
}
Logger.debug('Loaded ${config.nostrRelays.length} relay(s) from config');
// Initialize Immich service
Logger.debug('Initializing Immich service...');
final immichService = ImmichService(
baseUrl: config.immichBaseUrl,
apiKey: config.immichApiKey,
localStorage: storageService,
);
Logger.info('Immich service initialized');
// Load media server settings
final settingsItem = await storageService.getItem('app_settings');
// Default to Blossom if Immich is disabled, otherwise default to Immich
final defaultMediaServerType = config.immichEnabled ? 'immich' : 'blossom';
final mediaServerType = settingsItem?.data['media_server_type'] as String? ?? defaultMediaServerType;
final immichBaseUrl = settingsItem?.data['immich_base_url'] as String? ?? config.immichBaseUrl;
final immichApiKey = settingsItem?.data['immich_api_key'] as String? ?? config.immichApiKey;
final blossomBaseUrl = settingsItem?.data['blossom_base_url'] as String? ?? config.blossomServer;
// Initialize media service based on selection
MediaServiceInterface? mediaService;
if (mediaServerType == 'blossom' && blossomBaseUrl != null && blossomBaseUrl.isNotEmpty) {
Logger.debug('Initializing Blossom service...');
final blossomService = BlossomService(
baseUrl: blossomBaseUrl,
localStorage: storageService,
);
mediaService = blossomService;
Logger.info('Blossom service initialized');
} else if (config.immichEnabled && immichBaseUrl != null && immichBaseUrl.isNotEmpty && immichApiKey != null && immichApiKey.isNotEmpty) {
Logger.debug('Initializing Immich service...');
final immichService = ImmichService(
baseUrl: immichBaseUrl,
apiKey: immichApiKey,
localStorage: storageService,
);
mediaService = immichService;
Logger.info('Immich service initialized');
} else if (!config.immichEnabled && blossomBaseUrl != null && blossomBaseUrl.isNotEmpty) {
// If Immich is disabled and Blossom URL is available, use Blossom
Logger.debug('Initializing Blossom service (Immich disabled)...');
final blossomService = BlossomService(
baseUrl: blossomBaseUrl,
localStorage: storageService,
);
mediaService = blossomService;
Logger.info('Blossom service initialized');
} else {
Logger.warning('No media server configured');
}
// Initialize Firebase service if enabled
FirebaseService? firebaseService;
@ -169,7 +203,7 @@ class AppInitializer {
syncEngine: syncEngine,
firebaseService: firebaseService,
sessionService: sessionService,
immichService: immichService,
mediaService: mediaService,
recipeService: recipeService,
);
@ -180,7 +214,7 @@ class AppInitializer {
syncEngine: syncEngine,
firebaseService: firebaseService,
sessionService: sessionService,
immichService: immichService,
mediaService: mediaService,
recipeService: recipeService,
themeNotifier: themeNotifier,
);

@ -3,7 +3,7 @@ import '../data/nostr/nostr_service.dart';
import '../data/sync/sync_engine.dart';
import '../data/firebase/firebase_service.dart';
import '../data/session/session_service.dart';
import '../data/immich/immich_service.dart';
import '../data/media/media_service_interface.dart';
import '../data/recipes/recipe_service.dart';
/// Container for all application services.
@ -26,8 +26,8 @@ class AppServices {
/// Session service.
final SessionService? sessionService;
/// Immich service.
final ImmichService? immichService;
/// Media service (Immich or Blossom).
final MediaServiceInterface? mediaService;
/// Recipe service.
final RecipeService? recipeService;
@ -39,7 +39,7 @@ class AppServices {
this.syncEngine,
this.firebaseService,
this.sessionService,
this.immichService,
this.mediaService,
this.recipeService,
});

@ -0,0 +1,14 @@
/// Exception thrown when Blossom operations fail.
class BlossomException implements Exception {
/// Error message.
final String message;
/// HTTP status code (if applicable).
final int? statusCode;
BlossomException(this.message, [this.statusCode]);
@override
String toString() => 'BlossomException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
}

@ -25,8 +25,8 @@ class ServiceLocator {
/// Session service.
dynamic _sessionService;
/// Immich service.
dynamic _immichService;
/// Media service (Immich or Blossom).
dynamic _mediaService;
/// Recipe service.
dynamic _recipeService;
@ -43,7 +43,7 @@ class ServiceLocator {
dynamic syncEngine,
dynamic firebaseService,
dynamic sessionService,
dynamic immichService,
dynamic mediaService,
dynamic recipeService,
dynamic themeNotifier,
}) {
@ -52,7 +52,7 @@ class ServiceLocator {
_syncEngine = syncEngine;
_firebaseService = firebaseService;
_sessionService = sessionService;
_immichService = immichService;
_mediaService = mediaService;
_recipeService = recipeService;
_themeNotifier = themeNotifier;
}
@ -79,8 +79,17 @@ class ServiceLocator {
/// Gets the session service (nullable).
dynamic get sessionService => _sessionService;
/// Gets the Immich service (nullable).
dynamic get immichService => _immichService;
/// Gets the Media service (nullable) - Immich or Blossom.
dynamic get mediaService => _mediaService;
/// Gets the Immich service (nullable) - for backward compatibility.
/// Returns null if Blossom is selected.
dynamic get immichService {
if (_mediaService != null && _mediaService.runtimeType.toString().contains('ImmichService')) {
return _mediaService;
}
return null;
}
/// Gets the Recipe service (nullable).
dynamic get recipeService => _recipeService;
@ -95,7 +104,7 @@ class ServiceLocator {
_syncEngine = null;
_firebaseService = null;
_sessionService = null;
_immichService = null;
_mediaService = null;
_recipeService = null;
_themeNotifier = null;
}

@ -0,0 +1,262 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../../core/logger.dart';
import '../../core/exceptions/blossom_exception.dart';
import '../local/local_storage_service.dart';
import '../media/media_service_interface.dart';
import '../nostr/models/nostr_event.dart';
import '../nostr/models/nostr_keypair.dart';
import 'models/blossom_upload_response.dart';
/// Service for interacting with Blossom Media Server API.
///
/// Blossom uses Nostr authentication (kind 24242 authorization events).
/// See: https://github.com/hzrd149/blossom
class BlossomService implements MediaServiceInterface {
/// HTTP client for API requests.
final Dio _dio;
/// Local storage service for caching metadata.
final LocalStorageService _localStorage;
/// Blossom API base URL.
final String _baseUrl;
/// Nostr keypair for authentication.
NostrKeyPair? _nostrKeyPair;
/// Cache directory for storing fetched images.
Directory? _imageCacheDirectory;
/// Creates a [BlossomService] instance.
///
/// [baseUrl] - Blossom server base URL (e.g., 'https://blossom.example.com').
/// [localStorage] - Local storage service for caching metadata.
/// [dio] - Optional Dio instance for dependency injection (useful for testing).
BlossomService({
required String baseUrl,
required LocalStorageService localStorage,
Dio? dio,
}) : _baseUrl = baseUrl,
_localStorage = localStorage,
_dio = dio ?? Dio() {
_dio.options.baseUrl = baseUrl;
}
/// Sets the Nostr keypair for authentication.
void setNostrKeyPair(NostrKeyPair keypair) {
_nostrKeyPair = keypair;
}
/// Creates an authorization event (kind 24242) for Blossom authentication.
NostrEvent _createAuthorizationEvent() {
if (_nostrKeyPair == null) {
throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.');
}
// Create authorization event (kind 24242) per BUD-01
return NostrEvent.create(
content: '',
kind: 24242,
privateKey: _nostrKeyPair!.privateKey,
tags: [],
);
}
/// Uploads an image file to Blossom.
///
/// [imageFile] - The image file to upload.
///
/// Returns [BlossomUploadResponse] containing the blob hash and URL.
///
/// Throws [BlossomException] if upload fails.
Future<BlossomUploadResponse> uploadImageToBlossom(File imageFile) async {
if (_nostrKeyPair == null) {
throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.');
}
try {
if (!await imageFile.exists()) {
throw BlossomException('Image file does not exist: ${imageFile.path}');
}
// Read file bytes and calculate SHA256 hash
final fileBytes = await imageFile.readAsBytes();
final hash = sha256.convert(fileBytes);
final blobHash = hash.toString();
// Create authorization event
final authEvent = _createAuthorizationEvent();
// Prepare multipart form data
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
imageFile.path,
filename: imageFile.path.split('/').last,
),
});
// Upload to Blossom (PUT /upload per BUD-02)
final response = await _dio.put(
'/upload',
data: formData,
options: Options(
headers: {
'Authorization': jsonEncode(authEvent.toJson()), // Signed Nostr event
},
),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw BlossomException(
'Upload failed: ${response.statusMessage}',
response.statusCode,
);
}
// Parse response (blob descriptor per BUD-02)
final responseData = response.data as Map<String, dynamic>;
final uploadResponse = BlossomUploadResponse.fromJson(responseData);
Logger.info('Image uploaded to Blossom: ${uploadResponse.hash}');
return uploadResponse;
} on DioException catch (e) {
throw BlossomException(
'Upload failed: ${e.message ?? 'Unknown error'}',
e.response?.statusCode,
);
} catch (e) {
throw BlossomException('Upload failed: $e');
}
}
/// Uploads an image file (implements MediaServiceInterface).
@override
Future<Map<String, dynamic>> uploadImage(File imageFile) async {
final response = await uploadImageToBlossom(imageFile);
return {
'hash': response.hash,
'id': response.hash, // For compatibility
'url': response.url,
};
}
/// Fetches image bytes for a blob by hash with local caching.
///
/// [blobHash] - The SHA256 hash of the blob (or full URL).
/// [isThumbnail] - Whether to fetch thumbnail (true) or original image (false). Default: true.
///
/// Returns the image bytes as Uint8List.
///
/// Throws [BlossomException] if fetch fails.
Future<Uint8List> fetchImageBytes(String blobHash, {bool isThumbnail = true}) async {
await _ensureImageCacheDirectory();
// Check cache first
final cacheFilePath = _getCacheFilePath(blobHash, isThumbnail);
if (cacheFilePath.isNotEmpty) {
final cacheFile = File(cacheFilePath);
if (await cacheFile.exists()) {
try {
final cachedBytes = await cacheFile.readAsBytes();
Logger.debug('Loaded image from cache: $blobHash');
return Uint8List.fromList(cachedBytes);
} catch (e) {
Logger.warning('Failed to read cached image, fetching fresh: $e');
}
}
}
// Cache miss - fetch from Blossom
try {
// If blobHash is a full URL, use it directly
String imageUrl;
if (blobHash.startsWith('http://') || blobHash.startsWith('https://')) {
imageUrl = blobHash;
} else {
// Construct URL: GET /<sha256> per BUD-01
imageUrl = '$_baseUrl/$blobHash';
}
final response = await _dio.get<List<int>>(
imageUrl,
options: Options(
responseType: ResponseType.bytes,
),
);
if (response.statusCode != 200) {
throw BlossomException(
'Failed to fetch image: ${response.statusMessage}',
response.statusCode,
);
}
final imageBytes = Uint8List.fromList(response.data ?? []);
// Cache the fetched image
if (cacheFilePath.isNotEmpty && imageBytes.isNotEmpty) {
try {
final cacheFile = File(cacheFilePath);
await cacheFile.writeAsBytes(imageBytes);
Logger.debug('Cached image: $blobHash');
} catch (e) {
Logger.warning('Failed to cache image (non-fatal): $e');
}
}
return imageBytes;
} on DioException catch (e) {
throw BlossomException(
'Failed to fetch image: ${e.message ?? 'Unknown error'}',
e.response?.statusCode,
);
}
}
/// Gets the full image URL for a blob hash.
///
/// [blobHash] - The SHA256 hash of the blob (or full URL).
///
/// Returns the full URL to the blob.
String getImageUrl(String blobHash) {
// If it's already a full URL, return it
if (blobHash.startsWith('http://') || blobHash.startsWith('https://')) {
return blobHash;
}
// GET /<sha256> per BUD-01
return '$_baseUrl/$blobHash';
}
/// Gets the base URL for Blossom API.
String get baseUrl => _baseUrl;
/// Initializes the image cache directory.
Future<void> _ensureImageCacheDirectory() async {
if (_imageCacheDirectory != null) return;
try {
final appDir = await getApplicationDocumentsDirectory();
_imageCacheDirectory = Directory(path.join(appDir.path, 'blossom_image_cache'));
if (!await _imageCacheDirectory!.exists()) {
await _imageCacheDirectory!.create(recursive: true);
}
} catch (e) {
Logger.warning('Failed to initialize image cache directory: $e');
}
}
/// Gets the cache file path for an image.
String _getCacheFilePath(String blobHash, bool isThumbnail) {
if (_imageCacheDirectory == null) return '';
final cacheKey = '${blobHash}_${isThumbnail ? 'thumb' : 'full'}';
return path.join(_imageCacheDirectory!.path, cacheKey);
}
}

@ -0,0 +1,26 @@
/// Response model for Blossom blob upload (blob descriptor per BUD-02).
class BlossomUploadResponse {
/// The SHA256 hash of the blob.
final String hash;
/// The URL to access the blob.
final String url;
/// Optional size in bytes.
final int? size;
BlossomUploadResponse({
required this.hash,
required this.url,
this.size,
});
factory BlossomUploadResponse.fromJson(Map<String, dynamic> json) {
return BlossomUploadResponse(
hash: json['hash'] as String? ?? json['sha256'] as String? ?? '',
url: json['url'] as String? ?? '',
size: json['size'] as int?,
);
}
}

@ -8,6 +8,7 @@ import '../../core/logger.dart';
import '../../core/exceptions/immich_exception.dart';
import '../local/local_storage_service.dart';
import '../local/models/item.dart';
import '../media/media_service_interface.dart';
import 'models/immich_asset.dart';
import 'models/upload_response.dart';
@ -19,7 +20,7 @@ import 'models/upload_response.dart';
/// - Store image metadata locally after uploads
///
/// The service is modular and UI-independent, designed for offline-first behavior.
class ImmichService {
class ImmichService implements MediaServiceInterface {
/// HTTP client for API requests.
final Dio _dio;
@ -66,7 +67,7 @@ class ImmichService {
///
/// Throws [ImmichException] if upload fails.
/// Automatically stores metadata in local storage upon successful upload.
Future<UploadResponse> uploadImage(
Future<UploadResponse> uploadImageToImmich(
File imageFile, {
String? albumId,
}) async {
@ -261,6 +262,16 @@ class ImmichService {
}
}
/// Uploads an image file (implements MediaServiceInterface).
@override
Future<Map<String, dynamic>> uploadImage(File imageFile) async {
final response = await uploadImageToImmich(imageFile);
return {
'id': response.id,
'url': getImageUrl(response.id),
};
}
/// Fetches a list of assets from Immich.
///
/// Based on official Immich API documentation: https://api.immich.app/endpoints/search/searchAssets

@ -0,0 +1,19 @@
import 'dart:io';
import 'dart:typed_data';
/// Interface for media services (Immich, Blossom, etc.)
abstract class MediaServiceInterface {
/// Uploads an image file.
/// Returns a map with 'id' (or 'hash') and 'url' keys.
Future<Map<String, dynamic>> uploadImage(File imageFile);
/// Fetches image bytes for an asset/blob.
Future<Uint8List> fetchImageBytes(String assetId, {bool isThumbnail = true});
/// Gets the full image URL for an asset/blob.
String getImageUrl(String assetId);
/// Gets the base URL.
String get baseUrl;
}

@ -88,19 +88,20 @@ class NostrService {
/// Gets the list of configured relays.
///
/// Automatically disables any relays that are enabled but not connected,
/// since enabled should always mean connected.
/// Note: This method does NOT auto-disable relays that are enabled but not connected,
/// as connections may be in progress. Use [getEnabledAndConnectedRelays()] if you need
/// only relays that are both enabled and connected.
List<NostrRelay> getRelays() {
// Ensure enabled relays are actually connected
// If a relay is enabled but not connected, disable it
for (final relay in _relays) {
if (relay.isEnabled && !relay.isConnected) {
relay.isEnabled = false;
}
}
return List.unmodifiable(_relays);
}
/// Gets only relays that are both enabled AND connected.
///
/// This is useful for operations that require an active connection.
List<NostrRelay> getEnabledAndConnectedRelays() {
return _relays.where((r) => r.isEnabled && r.isConnected).toList();
}
/// Connects to a relay.
///
/// [relayUrl] - The URL of the relay to connect to.
@ -858,9 +859,9 @@ class NostrService {
// Wait for connection to be confirmed
// The service marks connection as established after 500ms if no errors occur
// or when the first message is received
// We'll wait up to 2 seconds, checking every 200ms
// We'll wait up to 5 seconds, checking every 200ms
bool connected = false;
for (int i = 0; i < 10; i++) {
for (int i = 0; i < 25; i++) {
await Future.delayed(const Duration(milliseconds: 200));
final relay = _relays.firstWhere(
(r) => r.url == relayUrl,
@ -875,7 +876,7 @@ class NostrService {
if (!connected) {
// Connection didn't establish within timeout - disable the relay
Logger.warning('Connection not established for NIP-05 relay: $relayUrl within 2 seconds - disabling');
Logger.warning('Connection not established for NIP-05 relay: $relayUrl within 5 seconds - disabling');
try {
disconnectRelay(relayUrl);
setRelayEnabled(relayUrl, false);

@ -826,10 +826,10 @@ class RecipeService {
try {
Logger.info('Fetching recipes from Nostr for public key: ${publicKey.substring(0, 16)}...');
// Get all enabled relays
final enabledRelays = _nostrService!.getRelays()
.where((relay) => relay.isEnabled)
.toList();
// Get all enabled relays (including those that may be connecting)
// We'll try to connect to them if needed
final allRelays = _nostrService!.getRelays();
final enabledRelays = allRelays.where((relay) => relay.isEnabled).toList();
if (enabledRelays.isEmpty) {
Logger.warning('No enabled relays available for fetching recipes');
@ -1361,18 +1361,44 @@ class RecipeService {
await _ensureInitializedOrReinitialize();
try {
final relays = _nostrService!.getRelays();
final enabledRelays = relays.where((r) => r.isEnabled && r.isConnected).toList();
// Get all enabled relays (including those that may be connecting)
// We'll try to connect to them if needed
final allRelays = _nostrService!.getRelays();
final enabledRelays = allRelays.where((r) => r.isEnabled).toList();
if (enabledRelays.isEmpty) {
Logger.warning('No enabled and connected relays available for fetching bookmark categories');
Logger.warning('No enabled relays available for fetching bookmark categories');
return 0;
}
// Try to connect to relays that aren't connected yet
for (final relay in enabledRelays) {
if (!relay.isConnected) {
try {
await _nostrService!.connectRelay(relay.url).timeout(
const Duration(seconds: 5),
onTimeout: () {
throw Exception('Connection timeout');
},
);
} catch (e) {
Logger.warning('Failed to connect to relay ${relay.url} for bookmark categories: $e');
// Continue to next relay
}
}
}
// Filter to only connected relays after connection attempts
final connectedRelays = enabledRelays.where((r) => r.isConnected).toList();
if (connectedRelays.isEmpty) {
Logger.warning('No 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) {
// Fetch from all connected relays
for (final relay in connectedRelays) {
try {
final relayCategories = await _queryBookmarkCategoriesFromRelay(
publicKey,

@ -191,6 +191,20 @@ class SessionService {
}
}
// Set Nostr keypair on BlossomService if Blossom is the selected media service
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService != null && storedPrivateKey != null) {
try {
// Check if it's a BlossomService by checking if it has setNostrKeyPair method
if (mediaService.runtimeType.toString().contains('BlossomService')) {
(mediaService as dynamic).setNostrKeyPair(keyPair);
Logger.info('Nostr keypair set on BlossomService for image uploads');
}
} catch (e) {
Logger.warning('Failed to set Nostr keypair on BlossomService: $e');
}
}
// Sync with Firebase if enabled
if (_firebaseService != null && _firebaseService!.isEnabled) {
try {
@ -207,6 +221,11 @@ class SessionService {
// Load preferred relays from NIP-05 if available
await _loadPreferredRelaysIfAvailable();
// Wait a bit for relay connections to establish before fetching recipes
// This ensures that if NIP-05 relays were automatically replaced and enabled,
// they have time to connect before we try to fetch recipes
await Future.delayed(const Duration(milliseconds: 500));
// Fetch recipes from Nostr for this user
await _fetchRecipesForUser(user);

@ -163,12 +163,12 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
Future<void> _uploadImages() async {
if (_selectedImages.isEmpty) return;
final immichService = ServiceLocator.instance.immichService;
if (immichService == null) {
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Immich service not available'),
content: Text('Media service not available'),
backgroundColor: Colors.orange,
),
);
@ -186,8 +186,9 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
for (final imageFile in _selectedImages) {
try {
final uploadResponse = await immichService.uploadImage(imageFile);
final imageUrl = immichService.getImageUrl(uploadResponse.id);
final uploadResult = await mediaService.uploadImage(imageFile);
// uploadResult contains 'id' or 'hash' and 'url'
final imageUrl = uploadResult['url'] as String? ?? mediaService.getImageUrl(uploadResult['id'] as String? ?? uploadResult['hash'] as String? ?? '');
uploadedUrls.add(imageUrl);
Logger.info('Image uploaded: $imageUrl');
} catch (e) {
@ -811,20 +812,30 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
);
}
/// Builds an image preview widget using ImmichService for authenticated access.
/// Builds an image preview widget using MediaService for authenticated access.
Widget _buildImagePreview(String imageUrl) {
final immichService = ServiceLocator.instance.immichService;
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return Image.network(imageUrl, fit: BoxFit.cover);
}
// Try to extract asset ID from URL (format: .../api/assets/{id}/original)
// 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 && 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 (assetIdMatch != null) {
assetId = assetIdMatch.group(1);
} else {
// For Blossom URLs, use the full URL or extract hash
// Blossom URLs are typically: {baseUrl}/{hash} or just {hash}
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 Container(
color: Theme.of(context).brightness == Brightness.dark
@ -850,7 +861,6 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
);
},
);
}
}
// Fallback to direct network image if not an Immich URL or service unavailable
@ -1249,15 +1259,25 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
/// Builds an image preview specifically for tiled layouts (ensures proper fit).
Widget _buildImagePreviewForTile(String imageUrl) {
final immichService = ServiceLocator.instance.immichService;
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return Image.network(imageUrl, fit: BoxFit.cover);
}
// 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 && immichService != null) {
final assetId = assetIdMatch.group(1);
if (assetId != null) {
return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true),
if (assetIdMatch != null) {
assetId = assetIdMatch.group(1);
} else {
// For Blossom URLs, use the full URL or extract hash
assetId = imageUrl;
}
if (assetId != null) {
return FutureBuilder<Uint8List?>(
future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
@ -1291,7 +1311,6 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
);
},
);
}
}
return Image.network(

@ -207,14 +207,6 @@ class _BookmarksScreenState extends State<BookmarksScreen> {
appBar: AppBar(
title: const Text('Bookmarks'),
actions: [
IconButton(
icon: const Icon(Icons.person),
tooltip: 'User',
onPressed: () {
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
],
),
body: RefreshIndicator(
@ -283,15 +275,30 @@ class _BookmarkRecipeItem extends StatelessWidget {
/// Builds an image widget using ImmichService for authenticated access.
Widget _buildRecipeImage(String imageUrl) {
final immichService = ServiceLocator.instance.immichService;
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return Container(
width: 60,
height: 60,
color: Colors.grey.shade200,
child: const Icon(Icons.image, size: 32),
);
}
// 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 && immichService != null) {
final assetId = assetIdMatch.group(1);
if (assetId != null) {
return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true),
if (assetIdMatch != null) {
assetId = assetIdMatch.group(1);
} else {
// For Blossom URLs, use the full URL
assetId = imageUrl;
}
if (assetId != null) {
return FutureBuilder<Uint8List?>(
future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
@ -317,7 +324,6 @@ class _BookmarkRecipeItem extends StatelessWidget {
);
},
);
}
}
return Image.network(

@ -212,15 +212,6 @@ class _FavouritesScreenState extends State<FavouritesScreen> {
appBar: AppBar(
title: const Text('Favourites'),
actions: [
// User icon
IconButton(
icon: const Icon(Icons.person),
tooltip: 'User',
onPressed: () {
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
// View mode toggle icons
IconButton(
icon: Icon(
@ -398,17 +389,27 @@ class _RecipeCard extends StatelessWidget {
);
}
/// Builds an image widget using ImmichService for authenticated access.
/// Builds an image widget using MediaService for authenticated access.
Widget _buildRecipeImage(String imageUrl) {
final immichService = ServiceLocator.instance.immichService;
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return Image.network(imageUrl, fit: BoxFit.cover);
}
// 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 && immichService != null) {
final assetId = assetIdMatch.group(1);
if (assetId != null) {
return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true),
if (assetIdMatch != null) {
assetId = assetIdMatch.group(1);
} else {
// For Blossom URLs, use the full URL
assetId = imageUrl;
}
if (assetId != null) {
return FutureBuilder<Uint8List?>(
future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
@ -434,7 +435,6 @@ class _RecipeCard extends StatelessWidget {
);
},
);
}
}
return Image.network(

@ -155,19 +155,28 @@ class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
);
}
/// Builds an image widget using ImmichService for authenticated access.
/// Builds an image widget using MediaService for authenticated access.
Widget _buildImage(String imageUrl) {
final immichService = ServiceLocator.instance.immichService;
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return Image.network(imageUrl, fit: BoxFit.contain);
}
// Try to extract asset ID from URL (format: .../api/assets/{id}/original)
// 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 (assetIdMatch != null && immichService != null) {
final assetId = assetIdMatch.group(1);
if (assetId != null) {
// Use ImmichService to fetch full image (not thumbnail) for gallery view
return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: false),
if (assetId != null) {
// Use MediaService to fetch full image (not thumbnail) for gallery view
return FutureBuilder<Uint8List?>(
future: mediaService.fetchImageBytes(assetId, isThumbnail: false),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
@ -193,7 +202,6 @@ class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
);
},
);
}
}
// Fallback to direct network image if not an Immich URL or service unavailable

@ -361,16 +361,6 @@ class _RecipesScreenState extends State<RecipesScreen> {
: const Text('All Recipes'),
elevation: 0,
actions: [
// User icon
IconButton(
icon: const Icon(Icons.person),
tooltip: 'User',
onPressed: () {
// Navigate to User screen
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
IconButton(
icon: Icon(_isSearching ? Icons.close : Icons.search),
onPressed: () {
@ -898,15 +888,25 @@ class _RecipeCard extends StatelessWidget {
}
Widget _buildRecipeImage(String imageUrl) {
final immichService = ServiceLocator.instance.immichService;
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return Image.network(imageUrl, fit: BoxFit.cover);
}
// 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 && immichService != null) {
final assetId = assetIdMatch.group(1);
if (assetId != null) {
return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true),
if (assetIdMatch != null) {
assetId = assetIdMatch.group(1);
} else {
// For Blossom URLs, use the full URL
assetId = imageUrl;
}
if (assetId != null) {
return FutureBuilder<Uint8List?>(
future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
@ -930,7 +930,6 @@ class _RecipeCard extends StatelessWidget {
);
},
);
}
}
return Image.network(

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'relay_management_controller.dart';
import '../../data/nostr/models/nostr_relay.dart';
import '../../core/service_locator.dart';
@ -27,6 +28,15 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
bool _isDarkMode = false;
bool _isLoadingSetting = true;
// Media server settings
String? _mediaServerType; // 'immich' or 'blossom'
String? _immichBaseUrl;
String? _immichApiKey;
String? _blossomBaseUrl;
final TextEditingController _immichUrlController = TextEditingController();
final TextEditingController _immichKeyController = TextEditingController();
final TextEditingController _blossomUrlController = TextEditingController();
@override
void initState() {
super.initState();
@ -36,6 +46,9 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
@override
void dispose() {
_urlController.dispose();
_immichUrlController.dispose();
_immichKeyController.dispose();
_blossomUrlController.dispose();
super.dispose();
}
@ -49,13 +62,33 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
return;
}
// Read Immich enabled status from env (default: true)
final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
// Read default Blossom server from env
final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com';
final settingsItem = await localStorage.getItem('app_settings');
if (settingsItem != null) {
final useNip05 = settingsItem.data['use_nip05_relays_automatically'] == true;
final isDark = settingsItem.data['dark_mode'] == true;
final mediaServer = settingsItem.data['media_server_type'] as String?;
final immichUrl = settingsItem.data['immich_base_url'] as String?;
final immichKey = settingsItem.data['immich_api_key'] as String?;
final blossomUrl = settingsItem.data['blossom_base_url'] as String?;
// Default to Blossom if Immich is disabled, otherwise default to Immich
final defaultMediaServerType = immichEnabled ? 'immich' : 'blossom';
setState(() {
_useNip05RelaysAutomatically = useNip05;
_isDarkMode = isDark;
_mediaServerType = mediaServer ?? defaultMediaServerType;
_immichBaseUrl = immichUrl;
_immichApiKey = immichKey;
_blossomBaseUrl = blossomUrl ?? defaultBlossomServer;
_immichUrlController.text = immichUrl ?? '';
_immichKeyController.text = immichKey ?? '';
_blossomUrlController.text = blossomUrl ?? defaultBlossomServer;
_isLoadingSetting = false;
});
// Update theme notifier if available
@ -65,6 +98,9 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
}
} else {
setState(() {
_mediaServerType = immichEnabled ? 'immich' : 'blossom';
_blossomBaseUrl = defaultBlossomServer;
_blossomUrlController.text = defaultBlossomServer;
_isLoadingSetting = false;
});
}
@ -103,6 +139,39 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
}
}
Future<void> _saveMediaServerSettings() async {
try {
final localStorage = ServiceLocator.instance.localStorageService;
if (localStorage == null) return;
final settingsItem = await localStorage.getItem('app_settings');
final data = settingsItem?.data ?? <String, dynamic>{};
data['media_server_type'] = _mediaServerType;
data['immich_base_url'] = _immichBaseUrl;
data['immich_api_key'] = _immichApiKey;
data['blossom_base_url'] = _blossomBaseUrl;
await localStorage.insertItem(Item(
id: 'app_settings',
data: data,
));
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Media server settings saved'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
// Log error
}
}
void _updateAppTheme(bool isDark) {
// Update the theme notifier, which MyApp is listening to
final themeNotifier = ServiceLocator.instance.themeNotifier;
@ -183,6 +252,105 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
// Media Server Settings
Builder(
builder: (context) {
// Read Immich enabled status from env
final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
return ExpansionTile(
title: const Text('Media Server'),
subtitle: Text(_mediaServerType == 'immich' ? 'Immich' : 'Blossom'),
initiallyExpanded: false,
children: [
// Media server selection - only show Immich if enabled
if (immichEnabled)
RadioListTile<String>(
title: const Text('Immich'),
subtitle: const Text('Requires API key and URL'),
value: 'immich',
groupValue: _mediaServerType,
onChanged: (value) {
setState(() {
_mediaServerType = value;
});
_saveMediaServerSettings();
},
),
RadioListTile<String>(
title: const Text('Blossom'),
subtitle: const Text('Requires URL only (uses Nostr auth)'),
value: 'blossom',
groupValue: _mediaServerType,
onChanged: (value) {
setState(() {
_mediaServerType = value;
});
_saveMediaServerSettings();
},
),
const Divider(),
// Immich settings (only show if Immich selected and enabled)
if (_mediaServerType == 'immich' && immichEnabled) ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _immichUrlController,
decoration: const InputDecoration(
labelText: 'Immich Base URL',
hintText: 'https://immich.example.com',
border: OutlineInputBorder(),
),
onChanged: (value) {
_immichBaseUrl = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
),
const SizedBox(height: 16),
TextField(
controller: _immichKeyController,
decoration: const InputDecoration(
labelText: 'Immich API Key',
border: OutlineInputBorder(),
),
obscureText: true,
onChanged: (value) {
_immichApiKey = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
),
],
),
),
],
// Blossom settings (only show if Blossom selected)
if (_mediaServerType == 'blossom') ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _blossomUrlController,
decoration: const InputDecoration(
labelText: 'Blossom Base URL',
hintText: 'https://blossom.example.com',
border: OutlineInputBorder(),
),
onChanged: (value) {
_blossomBaseUrl = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
),
),
],
],
);
},
),
],
),
),

@ -274,17 +274,30 @@ class _SessionScreenState extends State<SessionScreen> {
);
}
final immichService = ServiceLocator.instance.immichService;
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return CircleAvatar(
radius: radius,
backgroundColor: Colors.grey[300],
child: const Icon(Icons.person, size: 50),
);
}
// Try to extract asset ID from URL (format: .../api/assets/{id}/original)
// 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 && 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),
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(
@ -308,7 +321,6 @@ class _SessionScreenState extends State<SessionScreen> {
);
},
);
}
}
// Fallback to direct network image if not an Immich URL or service unavailable

@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'dart:typed_data';
import '../../core/service_locator.dart';
import '../../data/immich/immich_service.dart';
import '../navigation/main_navigation_scaffold.dart';
/// Primary AppBar widget with user icon for all main screens.
class PrimaryAppBar extends StatelessWidget implements PreferredSizeWidget {
class PrimaryAppBar extends StatefulWidget implements PreferredSizeWidget {
final String title;
const PrimaryAppBar({
@ -10,25 +13,187 @@ class PrimaryAppBar extends StatelessWidget implements PreferredSizeWidget {
required this.title,
});
@override
State<PrimaryAppBar> createState() => _PrimaryAppBarState();
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _PrimaryAppBarState extends State<PrimaryAppBar> {
Uint8List? _avatarBytes;
bool _isLoadingAvatar = false;
String? _lastProfilePictureUrl; // Track URL to avoid reloading
@override
void initState() {
super.initState();
_loadAvatar();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Only reload if the profile picture URL has changed
final sessionService = ServiceLocator.instance.sessionService;
if (sessionService != null && sessionService.isLoggedIn) {
final currentUser = sessionService.currentUser;
final profilePictureUrl = currentUser?.nostrProfile?.picture;
if (profilePictureUrl != _lastProfilePictureUrl) {
_lastProfilePictureUrl = profilePictureUrl;
_loadAvatar();
}
}
}
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
if (profilePictureUrl == _lastProfilePictureUrl && _avatarBytes != null) {
return;
}
_lastProfilePictureUrl = profilePictureUrl;
setState(() {
_isLoadingAvatar = true;
});
try {
// Extract asset ID from Immich URL using regex (same as SessionScreen)
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService != null) {
// Try to extract asset ID from URL (format: .../api/assets/{id}/original or .../share/{shareId})
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) {
final bytes = await mediaService.fetchImageBytes(assetId, isThumbnail: true);
if (mounted) {
setState(() {
_avatarBytes = bytes;
_isLoadingAvatar = false;
});
}
return;
}
}
// 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
debugPrint('Failed to load avatar: $e');
if (mounted) {
setState(() {
_isLoadingAvatar = false;
_avatarBytes = null; // Clear on error
});
}
}
}
@override
Widget build(BuildContext context) {
return AppBar(
title: Text(title),
title: Text(widget.title),
actions: [
IconButton(
icon: const Icon(Icons.person),
tooltip: 'User',
onPressed: () {
// Navigate to User screen by finding the MainNavigationScaffold
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
// Only show user icon on Home screen (title == 'Home')
if (widget.title == 'Home')
IconButton(
icon: _buildUserIcon(),
tooltip: 'User',
onPressed: () {
// Navigate to User screen by finding the MainNavigationScaffold
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
Widget _buildUserIcon() {
if (_isLoadingAvatar) {
return const CircleAvatar(
radius: 12,
backgroundColor: Colors.transparent,
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (_avatarBytes != null) {
return CircleAvatar(
radius: 12,
backgroundImage: MemoryImage(_avatarBytes!),
onBackgroundImageError: (_, __) {
// Clear avatar on error
if (mounted) {
setState(() {
_avatarBytes = null;
});
}
},
);
}
return const CircleAvatar(
radius: 12,
child: Icon(Icons.person, size: 16),
);
}
}

@ -123,12 +123,12 @@ class _UserEditScreenState extends State<UserEditScreen> {
Future<void> _uploadProfilePicture() async {
if (_selectedImageFile == null) return;
final immichService = ServiceLocator.instance.immichService;
if (immichService == null) {
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Immich service not available'),
content: Text('Media service not available'),
backgroundColor: Colors.orange,
),
);
@ -141,19 +141,25 @@ class _UserEditScreenState extends State<UserEditScreen> {
});
try {
final uploadResponse = await immichService.uploadImage(_selectedImageFile!);
final uploadResult = await mediaService.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
// Try to get a public shared link URL for the uploaded image (Immich only)
// For Blossom, the URL is already public
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');
final immichService = ServiceLocator.instance.immichService;
if (immichService != null && uploadResult.containsKey('id')) {
try {
final publicUrl = await immichService.getPublicUrlForAsset(uploadResult['id'] as String);
imageUrl = publicUrl;
Logger.info('Using public URL for profile picture: $imageUrl');
} catch (e) {
// Fallback to authenticated URL if public URL creation fails
imageUrl = mediaService.getImageUrl(uploadResult['id'] as String);
Logger.warning('Failed to create public URL, using authenticated URL: $e');
}
} else {
// For Blossom or if no ID, use the URL from upload result
imageUrl = uploadResult['url'] as String? ?? mediaService.getImageUrl(uploadResult['hash'] as String? ?? uploadResult['id'] as String? ?? '');
}
setState(() {
@ -297,17 +303,30 @@ class _UserEditScreenState extends State<UserEditScreen> {
);
}
final immichService = ServiceLocator.instance.immichService;
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return CircleAvatar(
radius: radius,
backgroundColor: Colors.grey[300],
child: const Icon(Icons.person, size: 50),
);
}
// Try to extract asset ID from URL (format: .../api/assets/{id}/original)
// 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 (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),
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(
@ -331,7 +350,6 @@ class _UserEditScreenState extends State<UserEditScreen> {
);
},
);
}
}
// Fallback to direct network image if not an Immich URL or service unavailable

@ -274,7 +274,7 @@ packages:
source: hosted
version: "0.3.5"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf

@ -16,6 +16,7 @@ dependencies:
dio: ^5.4.0
nostr_tools: ^1.0.9
flutter_dotenv: ^5.1.0
crypto: ^3.0.0
# Firebase dependencies (optional - can be disabled if not needed)
firebase_core: ^3.0.0
cloud_firestore: ^5.0.0

@ -82,7 +82,7 @@ void main() {
);
// Act
final response = await immichService.uploadImage(testFile);
final response = await immichService.uploadImageToImmich(testFile);
// Assert
expect(response.id, equals('asset-123'));
@ -109,7 +109,7 @@ void main() {
// Act & Assert
expect(
() => immichService.uploadImage(nonExistentFile),
() => immichService.uploadImageToImmich(nonExistentFile),
throwsA(isA<ImmichException>()),
);
});
@ -177,7 +177,7 @@ void main() {
);
// Act
final response = await immichService.uploadImage(testFile);
final response = await immichService.uploadImageToImmich(testFile);
// Assert
expect(response.duplicate, isTrue);

Loading…
Cancel
Save

Powered by TurnKey Linux.