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) # - pubspec.yaml (name field - used for package name)
APP_NAME=app_boilerplate 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 Configuration
IMMICH_BASE_URL=https://photos.satoshinakamoto.win IMMICH_BASE_URL=https://photos.satoshinakamoto.win
IMMICH_API_KEY_DEV=your-dev-api-key-here IMMICH_API_KEY_DEV=your-dev-api-key-here
IMMICH_API_KEY_PROD=your-prod-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 (comma-separated list)
NOSTR_RELAYS_DEV=wss://nostrum.satoshinakamoto.win,wss://nos.lol NOSTR_RELAYS_DEV=wss://nostrum.satoshinakamoto.win,wss://nos.lol
NOSTR_RELAYS_PROD=wss://relay.damus.io 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_DEV=your-dev-api-key-here
IMMICH_API_KEY_PROD=your-prod-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 (comma-separated list)
NOSTR_RELAYS_DEV=wss://nostrum.satoshinakamoto.win,wss://nos.lol NOSTR_RELAYS_DEV=wss://nostrum.satoshinakamoto.win,wss://nos.lol
NOSTR_RELAYS_PROD=wss://relay.damus.io NOSTR_RELAYS_PROD=wss://relay.damus.io

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

@ -78,6 +78,8 @@ class ConfigLoader {
enableLogging: getBoolEnv('ENABLE_LOGGING_DEV', true), enableLogging: getBoolEnv('ENABLE_LOGGING_DEV', true),
immichBaseUrl: getEnv('IMMICH_BASE_URL', 'https://photos.satoshinakamoto.win'), immichBaseUrl: getEnv('IMMICH_BASE_URL', 'https://photos.satoshinakamoto.win'),
immichApiKey: getEnv('IMMICH_API_KEY_DEV', 'your-dev-api-key-here'), 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', [ nostrRelays: getListEnv('NOSTR_RELAYS_DEV', [
'wss://nostrum.satoshinakamoto.win', 'wss://nostrum.satoshinakamoto.win',
'wss://nos.lol', 'wss://nos.lol',
@ -90,6 +92,8 @@ class ConfigLoader {
enableLogging: getBoolEnv('ENABLE_LOGGING_PROD', false), enableLogging: getBoolEnv('ENABLE_LOGGING_PROD', false),
immichBaseUrl: getEnv('IMMICH_BASE_URL', 'https://photos.satoshinakamoto.win'), immichBaseUrl: getEnv('IMMICH_BASE_URL', 'https://photos.satoshinakamoto.win'),
immichApiKey: getEnv('IMMICH_API_KEY_PROD', 'your-prod-api-key-here'), 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', [ nostrRelays: getListEnv('NOSTR_RELAYS_PROD', [
'wss://relay.damus.io', 'wss://relay.damus.io',
]), ]),

@ -7,6 +7,8 @@ import '../data/sync/sync_engine.dart';
import '../data/firebase/firebase_service.dart'; import '../data/firebase/firebase_service.dart';
import '../data/session/session_service.dart'; import '../data/session/session_service.dart';
import '../data/immich/immich_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 '../data/recipes/recipe_service.dart';
import 'app_services.dart'; import 'app_services.dart';
import 'service_locator.dart'; import 'service_locator.dart';
@ -96,14 +98,46 @@ class AppInitializer {
} }
Logger.debug('Loaded ${config.nostrRelays.length} relay(s) from config'); Logger.debug('Loaded ${config.nostrRelays.length} relay(s) from config');
// Initialize Immich service // 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...'); Logger.debug('Initializing Immich service...');
final immichService = ImmichService( final immichService = ImmichService(
baseUrl: config.immichBaseUrl, baseUrl: immichBaseUrl,
apiKey: config.immichApiKey, apiKey: immichApiKey,
localStorage: storageService, localStorage: storageService,
); );
mediaService = immichService;
Logger.info('Immich service initialized'); 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 // Initialize Firebase service if enabled
FirebaseService? firebaseService; FirebaseService? firebaseService;
@ -169,7 +203,7 @@ class AppInitializer {
syncEngine: syncEngine, syncEngine: syncEngine,
firebaseService: firebaseService, firebaseService: firebaseService,
sessionService: sessionService, sessionService: sessionService,
immichService: immichService, mediaService: mediaService,
recipeService: recipeService, recipeService: recipeService,
); );
@ -180,7 +214,7 @@ class AppInitializer {
syncEngine: syncEngine, syncEngine: syncEngine,
firebaseService: firebaseService, firebaseService: firebaseService,
sessionService: sessionService, sessionService: sessionService,
immichService: immichService, mediaService: mediaService,
recipeService: recipeService, recipeService: recipeService,
themeNotifier: themeNotifier, themeNotifier: themeNotifier,
); );

@ -3,7 +3,7 @@ import '../data/nostr/nostr_service.dart';
import '../data/sync/sync_engine.dart'; import '../data/sync/sync_engine.dart';
import '../data/firebase/firebase_service.dart'; import '../data/firebase/firebase_service.dart';
import '../data/session/session_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'; import '../data/recipes/recipe_service.dart';
/// Container for all application services. /// Container for all application services.
@ -26,8 +26,8 @@ class AppServices {
/// Session service. /// Session service.
final SessionService? sessionService; final SessionService? sessionService;
/// Immich service. /// Media service (Immich or Blossom).
final ImmichService? immichService; final MediaServiceInterface? mediaService;
/// Recipe service. /// Recipe service.
final RecipeService? recipeService; final RecipeService? recipeService;
@ -39,7 +39,7 @@ class AppServices {
this.syncEngine, this.syncEngine,
this.firebaseService, this.firebaseService,
this.sessionService, this.sessionService,
this.immichService, this.mediaService,
this.recipeService, 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. /// Session service.
dynamic _sessionService; dynamic _sessionService;
/// Immich service. /// Media service (Immich or Blossom).
dynamic _immichService; dynamic _mediaService;
/// Recipe service. /// Recipe service.
dynamic _recipeService; dynamic _recipeService;
@ -43,7 +43,7 @@ class ServiceLocator {
dynamic syncEngine, dynamic syncEngine,
dynamic firebaseService, dynamic firebaseService,
dynamic sessionService, dynamic sessionService,
dynamic immichService, dynamic mediaService,
dynamic recipeService, dynamic recipeService,
dynamic themeNotifier, dynamic themeNotifier,
}) { }) {
@ -52,7 +52,7 @@ class ServiceLocator {
_syncEngine = syncEngine; _syncEngine = syncEngine;
_firebaseService = firebaseService; _firebaseService = firebaseService;
_sessionService = sessionService; _sessionService = sessionService;
_immichService = immichService; _mediaService = mediaService;
_recipeService = recipeService; _recipeService = recipeService;
_themeNotifier = themeNotifier; _themeNotifier = themeNotifier;
} }
@ -79,8 +79,17 @@ class ServiceLocator {
/// Gets the session service (nullable). /// Gets the session service (nullable).
dynamic get sessionService => _sessionService; dynamic get sessionService => _sessionService;
/// Gets the Immich service (nullable). /// Gets the Media service (nullable) - Immich or Blossom.
dynamic get immichService => _immichService; 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). /// Gets the Recipe service (nullable).
dynamic get recipeService => _recipeService; dynamic get recipeService => _recipeService;
@ -95,7 +104,7 @@ class ServiceLocator {
_syncEngine = null; _syncEngine = null;
_firebaseService = null; _firebaseService = null;
_sessionService = null; _sessionService = null;
_immichService = null; _mediaService = null;
_recipeService = null; _recipeService = null;
_themeNotifier = 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 '../../core/exceptions/immich_exception.dart';
import '../local/local_storage_service.dart'; import '../local/local_storage_service.dart';
import '../local/models/item.dart'; import '../local/models/item.dart';
import '../media/media_service_interface.dart';
import 'models/immich_asset.dart'; import 'models/immich_asset.dart';
import 'models/upload_response.dart'; import 'models/upload_response.dart';
@ -19,7 +20,7 @@ import 'models/upload_response.dart';
/// - Store image metadata locally after uploads /// - Store image metadata locally after uploads
/// ///
/// The service is modular and UI-independent, designed for offline-first behavior. /// The service is modular and UI-independent, designed for offline-first behavior.
class ImmichService { class ImmichService implements MediaServiceInterface {
/// HTTP client for API requests. /// HTTP client for API requests.
final Dio _dio; final Dio _dio;
@ -66,7 +67,7 @@ class ImmichService {
/// ///
/// Throws [ImmichException] if upload fails. /// Throws [ImmichException] if upload fails.
/// Automatically stores metadata in local storage upon successful upload. /// Automatically stores metadata in local storage upon successful upload.
Future<UploadResponse> uploadImage( Future<UploadResponse> uploadImageToImmich(
File imageFile, { File imageFile, {
String? albumId, String? albumId,
}) async { }) 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. /// Fetches a list of assets from Immich.
/// ///
/// Based on official Immich API documentation: https://api.immich.app/endpoints/search/searchAssets /// 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. /// Gets the list of configured relays.
/// ///
/// Automatically disables any relays that are enabled but not connected, /// Note: This method does NOT auto-disable relays that are enabled but not connected,
/// since enabled should always mean connected. /// as connections may be in progress. Use [getEnabledAndConnectedRelays()] if you need
/// only relays that are both enabled and connected.
List<NostrRelay> getRelays() { 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); 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. /// Connects to a relay.
/// ///
/// [relayUrl] - The URL of the relay to connect to. /// [relayUrl] - The URL of the relay to connect to.
@ -858,9 +859,9 @@ class NostrService {
// Wait for connection to be confirmed // Wait for connection to be confirmed
// The service marks connection as established after 500ms if no errors occur // The service marks connection as established after 500ms if no errors occur
// or when the first message is received // 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; bool connected = false;
for (int i = 0; i < 10; i++) { for (int i = 0; i < 25; i++) {
await Future.delayed(const Duration(milliseconds: 200)); await Future.delayed(const Duration(milliseconds: 200));
final relay = _relays.firstWhere( final relay = _relays.firstWhere(
(r) => r.url == relayUrl, (r) => r.url == relayUrl,
@ -875,7 +876,7 @@ class NostrService {
if (!connected) { if (!connected) {
// Connection didn't establish within timeout - disable the relay // 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 { try {
disconnectRelay(relayUrl); disconnectRelay(relayUrl);
setRelayEnabled(relayUrl, false); setRelayEnabled(relayUrl, false);

@ -826,10 +826,10 @@ class RecipeService {
try { try {
Logger.info('Fetching recipes from Nostr for public key: ${publicKey.substring(0, 16)}...'); Logger.info('Fetching recipes from Nostr for public key: ${publicKey.substring(0, 16)}...');
// Get all enabled relays // Get all enabled relays (including those that may be connecting)
final enabledRelays = _nostrService!.getRelays() // We'll try to connect to them if needed
.where((relay) => relay.isEnabled) final allRelays = _nostrService!.getRelays();
.toList(); final enabledRelays = allRelays.where((relay) => relay.isEnabled).toList();
if (enabledRelays.isEmpty) { if (enabledRelays.isEmpty) {
Logger.warning('No enabled relays available for fetching recipes'); Logger.warning('No enabled relays available for fetching recipes');
@ -1361,18 +1361,44 @@ class RecipeService {
await _ensureInitializedOrReinitialize(); await _ensureInitializedOrReinitialize();
try { try {
final relays = _nostrService!.getRelays(); // Get all enabled relays (including those that may be connecting)
final enabledRelays = relays.where((r) => r.isEnabled && r.isConnected).toList(); // We'll try to connect to them if needed
final allRelays = _nostrService!.getRelays();
final enabledRelays = allRelays.where((r) => r.isEnabled).toList();
if (enabledRelays.isEmpty) { 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; return 0;
} }
final Map<String, BookmarkCategory> categoryMap = {}; // Track by category ID final Map<String, BookmarkCategory> categoryMap = {}; // Track by category ID
// Fetch from all enabled relays // Fetch from all connected relays
for (final relay in enabledRelays) { for (final relay in connectedRelays) {
try { try {
final relayCategories = await _queryBookmarkCategoriesFromRelay( final relayCategories = await _queryBookmarkCategoriesFromRelay(
publicKey, 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 // Sync with Firebase if enabled
if (_firebaseService != null && _firebaseService!.isEnabled) { if (_firebaseService != null && _firebaseService!.isEnabled) {
try { try {
@ -207,6 +221,11 @@ class SessionService {
// Load preferred relays from NIP-05 if available // Load preferred relays from NIP-05 if available
await _loadPreferredRelaysIfAvailable(); 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 // Fetch recipes from Nostr for this user
await _fetchRecipesForUser(user); await _fetchRecipesForUser(user);

@ -163,12 +163,12 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
Future<void> _uploadImages() async { Future<void> _uploadImages() async {
if (_selectedImages.isEmpty) return; if (_selectedImages.isEmpty) return;
final immichService = ServiceLocator.instance.immichService; final mediaService = ServiceLocator.instance.mediaService;
if (immichService == null) { if (mediaService == null) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Immich service not available'), content: Text('Media service not available'),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
@ -186,8 +186,9 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
for (final imageFile in _selectedImages) { for (final imageFile in _selectedImages) {
try { try {
final uploadResponse = await immichService.uploadImage(imageFile); final uploadResult = await mediaService.uploadImage(imageFile);
final imageUrl = immichService.getImageUrl(uploadResponse.id); // 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); uploadedUrls.add(imageUrl);
Logger.info('Image uploaded: $imageUrl'); Logger.info('Image uploaded: $imageUrl');
} catch (e) { } catch (e) {
@ -811,19 +812,29 @@ 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) { 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); 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 or extract hash
// Blossom URLs are typically: {baseUrl}/{hash} or just {hash}
assetId = imageUrl;
}
if (assetIdMatch != null && immichService != null) {
final assetId = assetIdMatch.group(1);
if (assetId != null) { if (assetId != null) {
// Use ImmichService to fetch image with proper authentication // Use MediaService to fetch image with proper authentication
return FutureBuilder<Uint8List?>( return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true), future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return Container( return Container(
@ -851,7 +862,6 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
}, },
); );
} }
}
// Fallback to direct network image if not an Immich URL or service unavailable // Fallback to direct network image if not an Immich URL or service unavailable
return Image.network( return Image.network(
@ -1249,15 +1259,25 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
/// Builds an image preview specifically for tiled layouts (ensures proper fit). /// Builds an image preview specifically for tiled layouts (ensures proper fit).
Widget _buildImagePreviewForTile(String imageUrl) { 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); 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 or extract hash
assetId = imageUrl;
}
if (assetIdMatch != null && immichService != null) {
final assetId = assetIdMatch.group(1);
if (assetId != null) { if (assetId != null) {
return FutureBuilder<Uint8List?>( return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true), future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return Container( return Container(
@ -1292,7 +1312,6 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
}, },
); );
} }
}
return Image.network( return Image.network(
imageUrl, imageUrl,

@ -207,14 +207,6 @@ class _BookmarksScreenState extends State<BookmarksScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Bookmarks'), title: const Text('Bookmarks'),
actions: [ actions: [
IconButton(
icon: const Icon(Icons.person),
tooltip: 'User',
onPressed: () {
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
], ],
), ),
body: RefreshIndicator( body: RefreshIndicator(
@ -283,15 +275,30 @@ class _BookmarkRecipeItem extends StatelessWidget {
/// Builds an image widget using ImmichService for authenticated access. /// Builds an image widget using ImmichService for authenticated access.
Widget _buildRecipeImage(String imageUrl) { 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); 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) { if (assetId != null) {
return FutureBuilder<Uint8List?>( return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true), future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return Container( return Container(
@ -318,7 +325,6 @@ class _BookmarkRecipeItem extends StatelessWidget {
}, },
); );
} }
}
return Image.network( return Image.network(
imageUrl, imageUrl,

@ -212,15 +212,6 @@ class _FavouritesScreenState extends State<FavouritesScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Favourites'), title: const Text('Favourites'),
actions: [ actions: [
// User icon
IconButton(
icon: const Icon(Icons.person),
tooltip: 'User',
onPressed: () {
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
// View mode toggle icons // View mode toggle icons
IconButton( IconButton(
icon: Icon( 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) { 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); 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) { if (assetId != null) {
return FutureBuilder<Uint8List?>( return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true), future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return Container( return Container(
@ -435,7 +436,6 @@ class _RecipeCard extends StatelessWidget {
}, },
); );
} }
}
return Image.network( return Image.network(
imageUrl, imageUrl,

@ -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) { 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); 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) { if (assetId != null) {
// Use ImmichService to fetch full image (not thumbnail) for gallery view // Use MediaService to fetch full image (not thumbnail) for gallery view
return FutureBuilder<Uint8List?>( return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: false), future: mediaService.fetchImageBytes(assetId, isThumbnail: false),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center( return const Center(
@ -194,7 +203,6 @@ class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
}, },
); );
} }
}
// Fallback to direct network image if not an Immich URL or service unavailable // Fallback to direct network image if not an Immich URL or service unavailable
return Image.network( return Image.network(

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

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'relay_management_controller.dart'; import 'relay_management_controller.dart';
import '../../data/nostr/models/nostr_relay.dart'; import '../../data/nostr/models/nostr_relay.dart';
import '../../core/service_locator.dart'; import '../../core/service_locator.dart';
@ -27,6 +28,15 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
bool _isDarkMode = false; bool _isDarkMode = false;
bool _isLoadingSetting = true; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@ -36,6 +46,9 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
@override @override
void dispose() { void dispose() {
_urlController.dispose(); _urlController.dispose();
_immichUrlController.dispose();
_immichKeyController.dispose();
_blossomUrlController.dispose();
super.dispose(); super.dispose();
} }
@ -49,13 +62,33 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
return; 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'); final settingsItem = await localStorage.getItem('app_settings');
if (settingsItem != null) { if (settingsItem != null) {
final useNip05 = settingsItem.data['use_nip05_relays_automatically'] == true; final useNip05 = settingsItem.data['use_nip05_relays_automatically'] == true;
final isDark = settingsItem.data['dark_mode'] == 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(() { setState(() {
_useNip05RelaysAutomatically = useNip05; _useNip05RelaysAutomatically = useNip05;
_isDarkMode = isDark; _isDarkMode = isDark;
_mediaServerType = mediaServer ?? defaultMediaServerType;
_immichBaseUrl = immichUrl;
_immichApiKey = immichKey;
_blossomBaseUrl = blossomUrl ?? defaultBlossomServer;
_immichUrlController.text = immichUrl ?? '';
_immichKeyController.text = immichKey ?? '';
_blossomUrlController.text = blossomUrl ?? defaultBlossomServer;
_isLoadingSetting = false; _isLoadingSetting = false;
}); });
// Update theme notifier if available // Update theme notifier if available
@ -65,6 +98,9 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
} }
} else { } else {
setState(() { setState(() {
_mediaServerType = immichEnabled ? 'immich' : 'blossom';
_blossomBaseUrl = defaultBlossomServer;
_blossomUrlController.text = defaultBlossomServer;
_isLoadingSetting = false; _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) { void _updateAppTheme(bool isDark) {
// Update the theme notifier, which MyApp is listening to // Update the theme notifier, which MyApp is listening to
final themeNotifier = ServiceLocator.instance.themeNotifier; final themeNotifier = ServiceLocator.instance.themeNotifier;
@ -183,6 +252,105 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
}, },
contentPadding: EdgeInsets.zero, 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); 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) { if (assetId != null) {
// Use ImmichService to fetch image with proper authentication // Use MediaService to fetch image with proper authentication
return FutureBuilder<Uint8List?>( return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true), future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return CircleAvatar( return CircleAvatar(
@ -309,7 +322,6 @@ class _SessionScreenState extends State<SessionScreen> {
}, },
); );
} }
}
// Fallback to direct network image if not an Immich URL or service unavailable // Fallback to direct network image if not an Immich URL or service unavailable
return CircleAvatar( return CircleAvatar(

@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; 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'; import '../navigation/main_navigation_scaffold.dart';
/// Primary AppBar widget with user icon for all main screens. /// 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; final String title;
const PrimaryAppBar({ const PrimaryAppBar({
@ -10,13 +13,144 @@ class PrimaryAppBar extends StatelessWidget implements PreferredSizeWidget {
required this.title, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppBar( return AppBar(
title: Text(title), title: Text(widget.title),
actions: [ actions: [
// Only show user icon on Home screen (title == 'Home')
if (widget.title == 'Home')
IconButton( IconButton(
icon: const Icon(Icons.person), icon: _buildUserIcon(),
tooltip: 'User', tooltip: 'User',
onPressed: () { onPressed: () {
// Navigate to User screen by finding the MainNavigationScaffold // Navigate to User screen by finding the MainNavigationScaffold
@ -28,7 +162,38 @@ class PrimaryAppBar extends StatelessWidget implements PreferredSizeWidget {
); );
} }
@override Widget _buildUserIcon() {
Size get preferredSize => const Size.fromHeight(kToolbarHeight); 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 { Future<void> _uploadProfilePicture() async {
if (_selectedImageFile == null) return; if (_selectedImageFile == null) return;
final immichService = ServiceLocator.instance.immichService; final mediaService = ServiceLocator.instance.mediaService;
if (immichService == null) { if (mediaService == null) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Immich service not available'), content: Text('Media service not available'),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
@ -141,20 +141,26 @@ class _UserEditScreenState extends State<UserEditScreen> {
}); });
try { 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 // Try to get a public shared link URL for the uploaded image (Immich only)
// This will be used in the Nostr profile so it can be accessed without authentication // For Blossom, the URL is already public
String imageUrl; String imageUrl;
final immichService = ServiceLocator.instance.immichService;
if (immichService != null && uploadResult.containsKey('id')) {
try { try {
final publicUrl = await immichService.getPublicUrlForAsset(uploadResponse.id); final publicUrl = await immichService.getPublicUrlForAsset(uploadResult['id'] as String);
imageUrl = publicUrl; imageUrl = publicUrl;
Logger.info('Using public URL for profile picture: $imageUrl'); Logger.info('Using public URL for profile picture: $imageUrl');
} catch (e) { } catch (e) {
// Fallback to authenticated URL if public URL creation fails // Fallback to authenticated URL if public URL creation fails
imageUrl = immichService.getImageUrl(uploadResponse.id); imageUrl = mediaService.getImageUrl(uploadResult['id'] as String);
Logger.warning('Failed to create public URL, using authenticated URL: $e'); 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(() { setState(() {
_profilePictureUrl = imageUrl; _profilePictureUrl = imageUrl;
@ -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); 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) { if (assetId != null) {
// Use ImmichService to fetch image with proper authentication // Use MediaService to fetch image with proper authentication
return FutureBuilder<Uint8List?>( return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true), future: mediaService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return CircleAvatar( return CircleAvatar(
@ -332,7 +351,6 @@ class _UserEditScreenState extends State<UserEditScreen> {
}, },
); );
} }
}
// Fallback to direct network image if not an Immich URL or service unavailable // Fallback to direct network image if not an Immich URL or service unavailable
return CircleAvatar( return CircleAvatar(

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

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.