diff --git a/.env.example b/.env.example index f574b7c..19c7bcf 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 85686b0..636c282 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index a92d52d..b6199c4 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -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 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, }); diff --git a/lib/config/config_loader.dart b/lib/config/config_loader.dart index 66b6b42..eeed277 100644 --- a/lib/config/config_loader.dart +++ b/lib/config/config_loader.dart @@ -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', ]), diff --git a/lib/core/app_initializer.dart b/lib/core/app_initializer.dart index 161a9b0..1ab46ce 100644 --- a/lib/core/app_initializer.dart +++ b/lib/core/app_initializer.dart @@ -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, ); diff --git a/lib/core/app_services.dart b/lib/core/app_services.dart index 072fdbf..32b31b5 100644 --- a/lib/core/app_services.dart +++ b/lib/core/app_services.dart @@ -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, }); diff --git a/lib/core/exceptions/blossom_exception.dart b/lib/core/exceptions/blossom_exception.dart new file mode 100644 index 0000000..e176f2b --- /dev/null +++ b/lib/core/exceptions/blossom_exception.dart @@ -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)' : ''}'; +} + diff --git a/lib/core/service_locator.dart b/lib/core/service_locator.dart index ff65f17..ede7fe9 100644 --- a/lib/core/service_locator.dart +++ b/lib/core/service_locator.dart @@ -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; } diff --git a/lib/data/blossom/blossom_service.dart b/lib/data/blossom/blossom_service.dart new file mode 100644 index 0000000..dd4a47a --- /dev/null +++ b/lib/data/blossom/blossom_service.dart @@ -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 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; + 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> 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 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 / per BUD-01 + imageUrl = '$_baseUrl/$blobHash'; + } + + final response = await _dio.get>( + 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 / per BUD-01 + return '$_baseUrl/$blobHash'; + } + + /// Gets the base URL for Blossom API. + String get baseUrl => _baseUrl; + + /// Initializes the image cache directory. + Future _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); + } +} + diff --git a/lib/data/blossom/models/blossom_upload_response.dart b/lib/data/blossom/models/blossom_upload_response.dart new file mode 100644 index 0000000..640b2bb --- /dev/null +++ b/lib/data/blossom/models/blossom_upload_response.dart @@ -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 json) { + return BlossomUploadResponse( + hash: json['hash'] as String? ?? json['sha256'] as String? ?? '', + url: json['url'] as String? ?? '', + size: json['size'] as int?, + ); + } +} + diff --git a/lib/data/immich/immich_service.dart b/lib/data/immich/immich_service.dart index 9f6140f..dee668d 100644 --- a/lib/data/immich/immich_service.dart +++ b/lib/data/immich/immich_service.dart @@ -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 uploadImage( + Future uploadImageToImmich( File imageFile, { String? albumId, }) async { @@ -261,6 +262,16 @@ class ImmichService { } } + /// Uploads an image file (implements MediaServiceInterface). + @override + Future> 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 diff --git a/lib/data/media/media_service_interface.dart b/lib/data/media/media_service_interface.dart new file mode 100644 index 0000000..5aa584c --- /dev/null +++ b/lib/data/media/media_service_interface.dart @@ -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> uploadImage(File imageFile); + + /// Fetches image bytes for an asset/blob. + Future 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; +} + diff --git a/lib/data/nostr/nostr_service.dart b/lib/data/nostr/nostr_service.dart index 40fba7e..7fa2d70 100644 --- a/lib/data/nostr/nostr_service.dart +++ b/lib/data/nostr/nostr_service.dart @@ -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 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 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); diff --git a/lib/data/recipes/recipe_service.dart b/lib/data/recipes/recipe_service.dart index 26a0861..7543c42 100644 --- a/lib/data/recipes/recipe_service.dart +++ b/lib/data/recipes/recipe_service.dart @@ -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 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, diff --git a/lib/data/session/session_service.dart b/lib/data/session/session_service.dart index 22084ff..be4cecb 100644 --- a/lib/data/session/session_service.dart +++ b/lib/data/session/session_service.dart @@ -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); diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index 7cc9828..fecb783 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -163,12 +163,12 @@ class _AddRecipeScreenState extends State { Future _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 { 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 { ); } - /// 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( - 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( + 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 { ); }, ); - } } // Fallback to direct network image if not an Immich URL or service unavailable @@ -1249,15 +1259,25 @@ class _AddRecipeScreenState extends State { /// 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( - 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( + future: mediaService.fetchImageBytes(assetId, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return Container( @@ -1291,7 +1311,6 @@ class _AddRecipeScreenState extends State { ); }, ); - } } return Image.network( diff --git a/lib/ui/bookmarks/bookmarks_screen.dart b/lib/ui/bookmarks/bookmarks_screen.dart index c88299a..201eefb 100644 --- a/lib/ui/bookmarks/bookmarks_screen.dart +++ b/lib/ui/bookmarks/bookmarks_screen.dart @@ -207,14 +207,6 @@ class _BookmarksScreenState extends State { appBar: AppBar( title: const Text('Bookmarks'), actions: [ - IconButton( - icon: const Icon(Icons.person), - tooltip: 'User', - onPressed: () { - final scaffold = context.findAncestorStateOfType(); - 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( - 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( + 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( diff --git a/lib/ui/favourites/favourites_screen.dart b/lib/ui/favourites/favourites_screen.dart index c8b59e4..d552b71 100644 --- a/lib/ui/favourites/favourites_screen.dart +++ b/lib/ui/favourites/favourites_screen.dart @@ -212,15 +212,6 @@ class _FavouritesScreenState extends State { appBar: AppBar( title: const Text('Favourites'), actions: [ - // User icon - IconButton( - icon: const Icon(Icons.person), - tooltip: 'User', - onPressed: () { - final scaffold = context.findAncestorStateOfType(); - 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( - 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( + 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( diff --git a/lib/ui/photo_gallery/photo_gallery_screen.dart b/lib/ui/photo_gallery/photo_gallery_screen.dart index bbc55b8..66fd7bf 100644 --- a/lib/ui/photo_gallery/photo_gallery_screen.dart +++ b/lib/ui/photo_gallery/photo_gallery_screen.dart @@ -155,19 +155,28 @@ class _PhotoGalleryScreenState extends State { ); } - /// 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( - future: immichService.fetchImageBytes(assetId, isThumbnail: false), + if (assetId != null) { + // Use MediaService to fetch full image (not thumbnail) for gallery view + return FutureBuilder( + 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 { ); }, ); - } } // Fallback to direct network image if not an Immich URL or service unavailable diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 1740299..8006b44 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -361,16 +361,6 @@ class _RecipesScreenState extends State { : 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(); - 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( - 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( + 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( diff --git a/lib/ui/relay_management/relay_management_screen.dart b/lib/ui/relay_management/relay_management_screen.dart index d4ee793..b3964a5 100644 --- a/lib/ui/relay_management/relay_management_screen.dart +++ b/lib/ui/relay_management/relay_management_screen.dart @@ -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'; @@ -26,6 +27,15 @@ class _RelayManagementScreenState extends State { bool _useNip05RelaysAutomatically = false; 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() { @@ -36,6 +46,9 @@ class _RelayManagementScreenState extends State { @override void dispose() { _urlController.dispose(); + _immichUrlController.dispose(); + _immichKeyController.dispose(); + _blossomUrlController.dispose(); super.dispose(); } @@ -49,13 +62,33 @@ class _RelayManagementScreenState extends State { 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 { } } else { setState(() { + _mediaServerType = immichEnabled ? 'immich' : 'blossom'; + _blossomBaseUrl = defaultBlossomServer; + _blossomUrlController.text = defaultBlossomServer; _isLoadingSetting = false; }); } @@ -103,6 +139,39 @@ class _RelayManagementScreenState extends State { } } + Future _saveMediaServerSettings() async { + try { + final localStorage = ServiceLocator.instance.localStorageService; + if (localStorage == null) return; + + final settingsItem = await localStorage.getItem('app_settings'); + final data = settingsItem?.data ?? {}; + + 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 { }, 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( + title: const Text('Immich'), + subtitle: const Text('Requires API key and URL'), + value: 'immich', + groupValue: _mediaServerType, + onChanged: (value) { + setState(() { + _mediaServerType = value; + }); + _saveMediaServerSettings(); + }, + ), + RadioListTile( + 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(); + }, + ), + ), + ], + ], + ); + }, + ), ], ), ), diff --git a/lib/ui/session/session_screen.dart b/lib/ui/session/session_screen.dart index ad7b92e..16785c0 100644 --- a/lib/ui/session/session_screen.dart +++ b/lib/ui/session/session_screen.dart @@ -274,17 +274,30 @@ class _SessionScreenState extends State { ); } - 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( - 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( + future: mediaService.fetchImageBytes(assetId, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return CircleAvatar( @@ -308,7 +321,6 @@ class _SessionScreenState extends State { ); }, ); - } } // Fallback to direct network image if not an Immich URL or service unavailable diff --git a/lib/ui/shared/primary_app_bar.dart b/lib/ui/shared/primary_app_bar.dart index 559ce6c..ae0aeec 100644 --- a/lib/ui/shared/primary_app_bar.dart +++ b/lib/ui/shared/primary_app_bar.dart @@ -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 createState() => _PrimaryAppBarState(); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +class _PrimaryAppBarState extends State { + 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 _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(); - 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(); + 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), + ); + } } diff --git a/lib/ui/user_edit/user_edit_screen.dart b/lib/ui/user_edit/user_edit_screen.dart index aeee323..2c2a429 100644 --- a/lib/ui/user_edit/user_edit_screen.dart +++ b/lib/ui/user_edit/user_edit_screen.dart @@ -123,12 +123,12 @@ class _UserEditScreenState extends State { Future _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 { }); 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 { ); } - 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( - future: immichService.fetchImageBytes(assetId, isThumbnail: true), + if (assetId != null) { + // Use MediaService to fetch image with proper authentication + return FutureBuilder( + future: mediaService.fetchImageBytes(assetId, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return CircleAvatar( @@ -331,7 +350,6 @@ class _UserEditScreenState extends State { ); }, ); - } } // Fallback to direct network image if not an Immich URL or service unavailable diff --git a/pubspec.lock b/pubspec.lock index 4ec6c8f..e58fb6a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -274,7 +274,7 @@ packages: source: hosted version: "0.3.5" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/pubspec.yaml b/pubspec.yaml index 6e81ad9..2b11b3e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/data/immich/immich_service_test.dart b/test/data/immich/immich_service_test.dart index 74938e6..d185757 100644 --- a/test/data/immich/immich_service_test.dart +++ b/test/data/immich/immich_service_test.dart @@ -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()), ); }); @@ -177,7 +177,7 @@ void main() { ); // Act - final response = await immichService.uploadImage(testFile); + final response = await immichService.uploadImageToImmich(testFile); // Assert expect(response.duplicate, isTrue);