From dcd4c37f3a4a13b253a7ba239c0492fd07cd40b4 Mon Sep 17 00:00:00 2001 From: gitea Date: Wed, 12 Nov 2025 21:30:12 +0100 Subject: [PATCH] multiple media server --- lib/core/app_initializer.dart | 132 ++- lib/core/service_locator.dart | 17 +- .../media/models/media_server_config.dart | 79 ++ lib/data/media/multi_media_service.dart | 278 +++++ lib/data/session/session_service.dart | 12 +- lib/ui/add_recipe/add_recipe_screen.dart | 86 +- .../navigation/main_navigation_scaffold.dart | 8 +- .../relay_management_controller.dart | 112 +- .../relay_management_screen.dart | 1051 ++++++++++++----- lib/ui/session/session_screen.dart | 54 +- .../main_navigation_scaffold_test.dart | 4 +- .../relay_management_screen_test.dart | 6 +- 12 files changed, 1400 insertions(+), 439 deletions(-) create mode 100644 lib/data/media/models/media_server_config.dart create mode 100644 lib/data/media/multi_media_service.dart diff --git a/lib/core/app_initializer.dart b/lib/core/app_initializer.dart index 1ab46ce..63b4d75 100644 --- a/lib/core/app_initializer.dart +++ b/lib/core/app_initializer.dart @@ -9,6 +9,9 @@ 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/media/multi_media_service.dart'; +import '../data/media/models/media_server_config.dart'; +import '../data/nostr/models/nostr_keypair.dart'; import '../data/recipes/recipe_service.dart'; import 'app_services.dart'; import 'service_locator.dart'; @@ -98,45 +101,59 @@ class AppInitializer { } Logger.debug('Loaded ${config.nostrRelays.length} relay(s) from config'); - // 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'); + // Initialize MultiMediaService for managing multiple media servers with fallback + Logger.debug('Initializing MultiMediaService...'); + final multiMediaService = MultiMediaService(localStorage: storageService); + await multiMediaService.loadServers(); + + // Migrate old single server config if no servers exist + if (multiMediaService.getServers().isEmpty) { + final settingsItem = await storageService.getItem('app_settings'); + final immichEnabled = config.immichEnabled; + final defaultBlossomServer = config.blossomServer; + + final mediaServerType = settingsItem?.data['media_server_type'] as String? ?? + (immichEnabled ? 'immich' : 'blossom'); + 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? ?? defaultBlossomServer; + + // Migrate Immich config + if (immichEnabled && mediaServerType == 'immich' && immichBaseUrl.isNotEmpty && immichApiKey.isNotEmpty) { + final config = MediaServerConfig( + id: 'immich-${DateTime.now().millisecondsSinceEpoch}', + type: 'immich', + baseUrl: immichBaseUrl, + apiKey: immichApiKey, + isDefault: true, + name: 'Immich Server', + ); + await multiMediaService.addServer(config); + Logger.info('Migrated Immich server config to MultiMediaService'); + } + + // Migrate Blossom config + if ((mediaServerType == 'blossom' || !immichEnabled) && blossomBaseUrl.isNotEmpty) { + final config = MediaServerConfig( + id: 'blossom-${DateTime.now().millisecondsSinceEpoch}', + type: 'blossom', + baseUrl: blossomBaseUrl, + isDefault: true, + name: 'Blossom Server', + ); + await multiMediaService.addServer(config); + Logger.info('Migrated Blossom server config to MultiMediaService'); + } + + await multiMediaService.saveServers(); + } + + MediaServiceInterface? mediaService = multiMediaService; + if (multiMediaService.getServers().isEmpty) { + Logger.warning('No media servers configured'); + mediaService = null; } else { - Logger.warning('No media server configured'); + Logger.info('MultiMediaService initialized with ${multiMediaService.getServers().length} server(s)'); } // Initialize Firebase service if enabled @@ -149,7 +166,8 @@ class AppInitializer { localStorage: storageService, ); await firebaseService.initialize(); - Logger.info('Firebase service initialized: ${firebaseService.isEnabled}'); + Logger.info( + 'Firebase service initialized: ${firebaseService.isEnabled}'); } catch (e) { Logger.error('Firebase service initialization failed: $e', e); firebaseService = null; @@ -222,5 +240,39 @@ class AppInitializer { Logger.info('Application initialization completed successfully'); return appServices; } -} + /// Reinitializes the media service based on current settings. + /// + /// This should be called when media server settings are changed. + /// Preserves the Nostr keypair if Blossom is selected. + static Future reinitializeMediaService() async { + Logger.info('Reinitializing media service...'); + + final localStorage = ServiceLocator.instance.localStorageService; + + if (localStorage == null) { + Logger.warning( + 'Cannot reinitialize media service: LocalStorageService not available'); + return; + } + + // Create new MultiMediaService instance and load servers + final multiMediaService = MultiMediaService(localStorage: localStorage); + await multiMediaService.loadServers(); + + MediaServiceInterface? newMediaService = multiMediaService; + if (multiMediaService.getServers().isEmpty) { + Logger.warning('No media servers configured'); + newMediaService = null; + } else { + Logger.info('MultiMediaService reinitialized with ${multiMediaService.getServers().length} server(s)'); + } + + // Register the new media service with ServiceLocator + ServiceLocator.instance.registerServices( + mediaService: newMediaService, + ); + + Logger.info('Media service reinitialized and registered successfully'); + } +} diff --git a/lib/core/service_locator.dart b/lib/core/service_locator.dart index ede7fe9..d679fba 100644 --- a/lib/core/service_locator.dart +++ b/lib/core/service_locator.dart @@ -37,6 +37,7 @@ class ServiceLocator { /// Registers all services with the locator. /// /// All services are optional and can be null if not configured. + /// If a service is not provided, the existing value is preserved. void registerServices({ dynamic localStorageService, dynamic nostrService, @@ -47,14 +48,14 @@ class ServiceLocator { dynamic recipeService, dynamic themeNotifier, }) { - _localStorageService = localStorageService; - _nostrService = nostrService; - _syncEngine = syncEngine; - _firebaseService = firebaseService; - _sessionService = sessionService; - _mediaService = mediaService; - _recipeService = recipeService; - _themeNotifier = themeNotifier; + if (localStorageService != null) _localStorageService = localStorageService; + if (nostrService != null) _nostrService = nostrService; + if (syncEngine != null) _syncEngine = syncEngine; + if (firebaseService != null) _firebaseService = firebaseService; + if (sessionService != null) _sessionService = sessionService; + if (mediaService != null) _mediaService = mediaService; + if (recipeService != null) _recipeService = recipeService; + if (themeNotifier != null) _themeNotifier = themeNotifier; } /// Gets the local storage service. diff --git a/lib/data/media/models/media_server_config.dart b/lib/data/media/models/media_server_config.dart new file mode 100644 index 0000000..97ab58f --- /dev/null +++ b/lib/data/media/models/media_server_config.dart @@ -0,0 +1,79 @@ +/// Represents a media server configuration. +class MediaServerConfig { + /// Unique identifier for this server configuration. + final String id; + + /// Server type: 'immich' or 'blossom'. + final String type; + + /// Base URL for the server. + final String baseUrl; + + /// API key (for Immich only). + final String? apiKey; + + /// Whether this is the default server. + final bool isDefault; + + /// Display name for this server (optional). + final String? name; + + /// Creates a [MediaServerConfig]. + MediaServerConfig({ + required this.id, + required this.type, + required this.baseUrl, + this.apiKey, + this.isDefault = false, + this.name, + }); + + /// Creates a [MediaServerConfig] from a JSON map. + factory MediaServerConfig.fromJson(Map json) { + return MediaServerConfig( + id: json['id'] as String, + type: json['type'] as String, + baseUrl: json['baseUrl'] as String, + apiKey: json['apiKey'] as String?, + isDefault: json['isDefault'] as bool? ?? false, + name: json['name'] as String?, + ); + } + + /// Converts [MediaServerConfig] to JSON. + Map toJson() { + return { + 'id': id, + 'type': type, + 'baseUrl': baseUrl, + 'apiKey': apiKey, + 'isDefault': isDefault, + 'name': name, + }; + } + + /// Creates a copy with updated fields. + MediaServerConfig copyWith({ + String? id, + String? type, + String? baseUrl, + String? apiKey, + bool? isDefault, + String? name, + }) { + return MediaServerConfig( + id: id ?? this.id, + type: type ?? this.type, + baseUrl: baseUrl ?? this.baseUrl, + apiKey: apiKey ?? this.apiKey, + isDefault: isDefault ?? this.isDefault, + name: name ?? this.name, + ); + } + + @override + String toString() { + return 'MediaServerConfig(id: $id, type: $type, baseUrl: $baseUrl, isDefault: $isDefault)'; + } +} + diff --git a/lib/data/media/multi_media_service.dart b/lib/data/media/multi_media_service.dart new file mode 100644 index 0000000..d5e4758 --- /dev/null +++ b/lib/data/media/multi_media_service.dart @@ -0,0 +1,278 @@ +import 'dart:io'; +import 'dart:typed_data'; +import '../../core/logger.dart'; +import '../../core/service_locator.dart'; +import '../../data/local/local_storage_service.dart'; +import '../../data/local/models/item.dart'; +import '../../data/nostr/models/nostr_keypair.dart'; +import '../../data/immich/immich_service.dart'; +import '../../data/blossom/blossom_service.dart'; +import 'media_service_interface.dart'; +import 'models/media_server_config.dart'; + +/// Wrapper service that manages multiple media servers with fallback support. +/// +/// When uploading, it tries the default server first, then falls back to other servers +/// if the default fails. +class MultiMediaService implements MediaServiceInterface { + final LocalStorageService _localStorage; + final List _servers = []; + MediaServerConfig? _defaultServer; + + /// Creates a [MultiMediaService] instance. + MultiMediaService({ + required LocalStorageService localStorage, + }) : _localStorage = localStorage; + + /// Loads media server configurations from storage. + Future loadServers() async { + try { + final serversItem = await _localStorage.getItem('media_servers'); + if (serversItem != null && serversItem.data['servers'] != null) { + final serversList = serversItem.data['servers'] as List; + _servers.clear(); + for (final serverJson in serversList) { + final config = MediaServerConfig.fromJson(serverJson as Map); + _servers.add(config); + if (config.isDefault) { + _defaultServer = config; + } + } + Logger.info('Loaded ${_servers.length} media server(s)'); + } + } catch (e) { + Logger.error('Failed to load media servers: $e', e); + } + } + + /// Saves media server configurations to storage. + Future saveServers() async { + try { + await _localStorage.insertItem( + Item( + id: 'media_servers', + data: { + 'servers': _servers.map((s) => s.toJson()).toList(), + }, + ), + ); + Logger.info('Saved ${_servers.length} media server(s)'); + } catch (e) { + Logger.error('Failed to save media servers: $e', e); + } + } + + /// Adds a new media server configuration. + Future addServer(MediaServerConfig config) async { + // If this is the first server or it's marked as default, set it as default + if (_servers.isEmpty || config.isDefault) { + // Unset other defaults + for (var server in _servers) { + if (server.isDefault) { + final index = _servers.indexOf(server); + _servers[index] = server.copyWith(isDefault: false); + } + } + _defaultServer = config; + } + _servers.add(config); + await saveServers(); + } + + /// Updates an existing media server configuration. + Future updateServer(String id, MediaServerConfig config) async { + final index = _servers.indexWhere((s) => s.id == id); + if (index != -1) { + // If setting as default, unset other defaults + if (config.isDefault) { + for (var server in _servers) { + if (server.isDefault && server.id != id) { + final otherIndex = _servers.indexOf(server); + _servers[otherIndex] = server.copyWith(isDefault: false); + } + } + _defaultServer = config; + } + _servers[index] = config; + await saveServers(); + } + } + + /// Removes a media server configuration. + Future removeServer(String id) async { + _servers.removeWhere((s) => s.id == id); + if (_defaultServer?.id == id) { + _defaultServer = _servers.isNotEmpty ? _servers.first : null; + if (_defaultServer != null) { + final index = _servers.indexOf(_defaultServer!); + _servers[index] = _defaultServer!.copyWith(isDefault: true); + } + } + await saveServers(); + } + + /// Gets all media server configurations. + List getServers() => List.unmodifiable(_servers); + + /// Gets the default media server configuration. + MediaServerConfig? getDefaultServer() => _defaultServer; + + /// Sets the default media server. + Future setDefaultServer(String id) async { + // Unset current default + for (var server in _servers) { + if (server.isDefault) { + final index = _servers.indexOf(server); + _servers[index] = server.copyWith(isDefault: false); + } + } + + // Set new default + final index = _servers.indexWhere((s) => s.id == id); + if (index != -1) { + _servers[index] = _servers[index].copyWith(isDefault: true); + _defaultServer = _servers[index]; + await saveServers(); + } + } + + /// Creates a media service instance from a configuration. + MediaServiceInterface? _createServiceFromConfig(MediaServerConfig config) { + try { + if (config.type == 'blossom') { + final service = BlossomService( + baseUrl: config.baseUrl, + localStorage: _localStorage, + ); + + // Set Nostr keypair if user is logged in + final sessionService = ServiceLocator.instance.sessionService; + if (sessionService != null && sessionService.isLoggedIn) { + final currentUser = sessionService.currentUser; + if (currentUser?.nostrPrivateKey != null) { + try { + final privateKey = currentUser!.nostrPrivateKey!; + final keypair = privateKey.startsWith('nsec') + ? NostrKeyPair.fromNsec(privateKey) + : NostrKeyPair.fromHexPrivateKey(privateKey); + service.setNostrKeyPair(keypair); + } catch (e) { + Logger.warning('Failed to set Nostr keypair for Blossom: $e'); + } + } + } + + return service; + } else if (config.type == 'immich') { + if (config.apiKey == null || config.apiKey!.isEmpty) { + Logger.warning('Immich server ${config.id} missing API key'); + return null; + } + return ImmichService( + baseUrl: config.baseUrl, + apiKey: config.apiKey!, + localStorage: _localStorage, + ); + } + } catch (e) { + Logger.error('Failed to create media service from config: $e', e); + } + return null; + } + + @override + Future> uploadImage(File imageFile) async { + if (_servers.isEmpty) { + throw Exception('No media servers configured'); + } + + // Start with default server, then try others + final serversToTry = []; + if (_defaultServer != null) { + serversToTry.add(_defaultServer!); + } + serversToTry.addAll(_servers.where((s) => s.id != _defaultServer?.id)); + + Exception? lastException; + for (final config in serversToTry) { + try { + Logger.info('Attempting upload to ${config.type} server: ${config.baseUrl}'); + final service = _createServiceFromConfig(config); + if (service == null) { + Logger.warning('Failed to create service for ${config.id}, trying next...'); + continue; + } + + final result = await service.uploadImage(imageFile); + Logger.info('Upload successful to ${config.type} server: ${config.baseUrl}'); + return result; + } catch (e) { + Logger.warning('Upload failed to ${config.type} server ${config.baseUrl}: $e'); + lastException = e is Exception ? e : Exception(e.toString()); + // Continue to next server + } + } + + // All servers failed + throw lastException ?? Exception('All media servers failed'); + } + + @override + Future fetchImageBytes(String assetId, {bool isThumbnail = true}) async { + if (_servers.isEmpty) { + throw Exception('No media servers configured'); + } + + // Try default server first, then others + final serversToTry = []; + if (_defaultServer != null) { + serversToTry.add(_defaultServer!); + } + serversToTry.addAll(_servers.where((s) => s.id != _defaultServer?.id)); + + Exception? lastException; + for (final config in serversToTry) { + try { + final service = _createServiceFromConfig(config); + if (service == null) continue; + + return await service.fetchImageBytes(assetId, isThumbnail: isThumbnail); + } catch (e) { + Logger.warning('Failed to fetch image from ${config.type} server ${config.baseUrl}: $e'); + lastException = e is Exception ? e : Exception(e.toString()); + // Continue to next server + } + } + + throw lastException ?? Exception('All media servers failed'); + } + + @override + String getImageUrl(String assetId) { + if (_defaultServer != null) { + final service = _createServiceFromConfig(_defaultServer!); + if (service != null) { + return service.getImageUrl(assetId); + } + } + + // Fallback to first available server + if (_servers.isNotEmpty) { + final service = _createServiceFromConfig(_servers.first); + if (service != null) { + return service.getImageUrl(assetId); + } + } + + return assetId; // Fallback to raw asset ID + } + + @override + String get baseUrl { + if (_defaultServer != null) { + return _defaultServer!.baseUrl; + } + return _servers.isNotEmpty ? _servers.first.baseUrl : ''; + } +} + diff --git a/lib/data/session/session_service.dart b/lib/data/session/session_service.dart index 4cd55df..9ff3fcf 100644 --- a/lib/data/session/session_service.dart +++ b/lib/data/session/session_service.dart @@ -664,13 +664,13 @@ class SessionService { } } else { // Just add preferred relays to existing list - final addedCount = await _nostrService!.loadPreferredRelaysFromNip05( - nip05, - publicKey, - ); + final addedCount = await _nostrService!.loadPreferredRelaysFromNip05( + nip05, + publicKey, + ); - if (addedCount > 0) { - Logger.info('Loaded $addedCount preferred relay(s) from NIP-05: $nip05'); + if (addedCount > 0) { + Logger.info('Loaded $addedCount preferred relay(s) from NIP-05: $nip05'); } } } catch (e) { diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index fecb783..ab78804 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -183,6 +183,7 @@ class _AddRecipeScreenState extends State { try { final List uploadedUrls = []; + final List failedImages = []; for (final imageFile in _selectedImages) { try { @@ -193,36 +194,99 @@ class _AddRecipeScreenState extends State { Logger.info('Image uploaded: $imageUrl'); } catch (e) { Logger.warning('Failed to upload image ${imageFile.path}: $e'); - // Continue with other images + failedImages.add(imageFile); + + // Show user-friendly error message + final errorMessage = _getUploadErrorMessage(e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } } } setState(() { _uploadedImageUrls.addAll(uploadedUrls); - _selectedImages.clear(); + // Keep only failed images in the selected list for retry + _selectedImages = failedImages; _isUploading = false; }); - if (mounted && uploadedUrls.isNotEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${uploadedUrls.length} image(s) uploaded successfully'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 1), - ), - ); + if (mounted) { + if (uploadedUrls.isNotEmpty && failedImages.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${uploadedUrls.length} image(s) uploaded successfully'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 1), + ), + ); + } else if (uploadedUrls.isNotEmpty && failedImages.isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${uploadedUrls.length} image(s) uploaded, ${failedImages.length} failed'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 2), + ), + ); + } } } catch (e) { Logger.error('Failed to upload images', e); if (mounted) { setState(() { _isUploading = false; - _errorMessage = 'Failed to upload images: $e'; + _errorMessage = _getUploadErrorMessage(e); }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_errorMessage!), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); } } } + /// Gets a user-friendly error message for upload failures. + String _getUploadErrorMessage(dynamic error) { + final errorString = error.toString().toLowerCase(); + + // Check for connection errors (host lookup, DNS, network issues) + if (errorString.contains('failed host lookup') || + errorString.contains('connection error') || + errorString.contains('network') || + errorString.contains('dns') || + errorString.contains('unreachable') || + errorString.contains('connection errored')) { + return 'Upload failed: Cannot connect to media server. Please check your media server settings (URL and configuration).'; + } + + // Check for authentication errors + if (errorString.contains('unauthorized') || + errorString.contains('401') || + errorString.contains('authentication')) { + return 'Upload failed: Authentication error. Please check your media server credentials.'; + } + + // Check for server errors + if (errorString.contains('500') || + errorString.contains('502') || + errorString.contains('503') || + errorString.contains('server error')) { + return 'Upload failed: Media server error. Please try again later.'; + } + + // Generic error message + return 'Upload failed. Please check your media server settings and try again.'; + } + Future _saveRecipe() async { if (!_formKey.currentState!.validate()) { return; diff --git a/lib/ui/navigation/main_navigation_scaffold.dart b/lib/ui/navigation/main_navigation_scaffold.dart index 85e2225..4513fc2 100644 --- a/lib/ui/navigation/main_navigation_scaffold.dart +++ b/lib/ui/navigation/main_navigation_scaffold.dart @@ -117,7 +117,7 @@ class MainNavigationScaffoldState extends State { if (result == true) { setState(() { _currentIndex = 1; // Switch to Recipes tab - }); + }); } } @@ -217,7 +217,7 @@ class MainNavigationScaffoldState extends State { label: 'Recipes', index: 1, onTap: () => _onItemTapped(1), - ), + ), ], ), ), @@ -314,8 +314,8 @@ class MainNavigationScaffoldState extends State { ?.withOpacity(0.6), fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), - ), - ], + ), + ], ), ), ), diff --git a/lib/ui/relay_management/relay_management_controller.dart b/lib/ui/relay_management/relay_management_controller.dart index 05a8b85..1b9e89a 100644 --- a/lib/ui/relay_management/relay_management_controller.dart +++ b/lib/ui/relay_management/relay_management_controller.dart @@ -151,8 +151,8 @@ class RelayManagementController extends ChangeNotifier { // Connection successful - enable the relay Logger.info('Connection successful for relay: $relayUrl - enabling relay'); nostrService.setRelayEnabled(relayUrl, true); - _loadRelays(); - return true; + _loadRelays(); + return true; } else { // Connection failed - leave it disabled Logger.warning('Connection failed for relay: $relayUrl (connected=$connected, gotError=$gotError) - leaving disabled'); @@ -363,7 +363,7 @@ class RelayManagementController extends ChangeNotifier { try { nostrService.disconnectRelay(relayUrl); nostrService.setRelayEnabled(relayUrl, false); - _loadRelays(); + _loadRelays(); _error = 'Failed to connect to relay: ${e.toString().replaceAll('Exception: ', '')}'; notifyListeners(); } catch (_) { @@ -376,66 +376,52 @@ class RelayManagementController extends ChangeNotifier { } } - /// Toggles all relays on/off. + /// Turns all disabled relays ON. /// - /// When enabling, automatically attempts to connect to all relays. - /// Always attempts to reconnect when toggling on, even if previously failed. - Future toggleAllRelays() async { + /// Only affects relays that are currently disabled. + /// Automatically attempts to connect to newly enabled relays. + Future turnAllOn() async { try { _error = null; - final allEnabled = _relays.every((r) => r.isEnabled); - final newEnabledState = !allEnabled; - // If disabling, just disconnect all (no test needed) - if (!newEnabledState) { - Logger.info('Toggling all relays OFF'); - try { - nostrService.setAllRelaysEnabled(false); - for (final relay in _relays) { - try { - nostrService.disconnectRelay(relay.url); - } catch (_) { - // Ignore disconnect errors - } - } - _loadRelays(); - } catch (_) { - // Ignore errors - } + // Find all disabled relays + final disabledRelays = _relays.where((r) => !r.isEnabled).toList(); + if (disabledRelays.isEmpty) { + Logger.info('No disabled relays to turn on'); return; } - // If enabling, first ensure all are enabled, then attempt to reconnect - Logger.info('Toggling all relays ON - attempting to connect to all'); + Logger.info('Turning ${disabledRelays.length} relay(s) ON'); - // Disconnect all first to ensure fresh connection attempts - final currentRelayUrls = _relays.map((r) => r.url).toList(); - for (final relayUrl in currentRelayUrls) { + // Disconnect disabled relays first to ensure fresh connection attempts + for (final relay in disabledRelays) { try { - nostrService.disconnectRelay(relayUrl); + nostrService.disconnectRelay(relay.url); } catch (_) { // Ignore if not connected } } - // Enable all relays immediately and update UI (optimistic update) - nostrService.setAllRelaysEnabled(true); - - // Update UI immediately by updating relays in our list directly - // This bypasses the auto-disable logic in getRelays() during connection attempts - for (var i = 0; i < _relays.length; i++) { - _relays[i] = NostrRelay( - url: _relays[i].url, - isConnected: _relays[i].isConnected, - isEnabled: true, - ); + // Enable disabled relays immediately and update UI (optimistic update) + for (final relay in disabledRelays) { + nostrService.setRelayEnabled(relay.url, true); + + // Update UI immediately + final relayIndex = _relays.indexWhere((r) => r.url == relay.url); + if (relayIndex != -1) { + _relays[relayIndex] = NostrRelay( + url: relay.url, + isConnected: relay.isConnected, + isEnabled: true, + ); + } } notifyListeners(); // Update UI immediately // Capture relay URLs before starting connections (list might change) - final relayUrls = _relays.map((r) => r.url).toList(); + final relayUrls = disabledRelays.map((r) => r.url).toList(); - // Now attempt to connect to all relays in parallel + // Now attempt to connect to all newly enabled relays in parallel final futures = >[]; for (final relayUrl in relayUrls) { futures.add( @@ -510,8 +496,42 @@ class RelayManagementController extends ChangeNotifier { Logger.info('All relay connection attempts completed'); _loadRelays(); } catch (e) { - Logger.error('Failed to toggle all relays', e); - _error = 'Failed to toggle all relays: $e'; + Logger.error('Failed to turn all relays on', e); + _error = 'Failed to turn all relays on: $e'; + notifyListeners(); + } + } + + /// Turns all enabled relays OFF. + /// + /// Only affects relays that are currently enabled. + Future turnAllOff() async { + try { + _error = null; + + // Find all enabled relays + final enabledRelays = _relays.where((r) => r.isEnabled).toList(); + if (enabledRelays.isEmpty) { + Logger.info('No enabled relays to turn off'); + return; + } + + Logger.info('Turning ${enabledRelays.length} relay(s) OFF'); + + // Disconnect and disable all enabled relays + for (final relay in enabledRelays) { + try { + nostrService.setRelayEnabled(relay.url, false); + nostrService.disconnectRelay(relay.url); + } catch (_) { + // Ignore disconnect errors + } + } + + _loadRelays(); + } catch (e) { + Logger.error('Failed to turn all relays off', e); + _error = 'Failed to turn all relays off: $e'; notifyListeners(); } } diff --git a/lib/ui/relay_management/relay_management_screen.dart b/lib/ui/relay_management/relay_management_screen.dart index b3964a5..6209517 100644 --- a/lib/ui/relay_management/relay_management_screen.dart +++ b/lib/ui/relay_management/relay_management_screen.dart @@ -3,7 +3,11 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'relay_management_controller.dart'; import '../../data/nostr/models/nostr_relay.dart'; import '../../core/service_locator.dart'; +import '../../core/logger.dart'; +import '../../core/app_initializer.dart'; import '../../data/local/models/item.dart'; +import '../../data/media/multi_media_service.dart'; +import '../../data/media/models/media_server_config.dart'; /// Screen for managing Nostr relays. /// @@ -27,28 +31,101 @@ class _RelayManagementScreenState extends State { bool _useNip05RelaysAutomatically = false; bool _isDarkMode = false; bool _isLoadingSetting = true; + bool _hasUnsavedChanges = false; // Track if settings have been modified - // 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(); + // Media server settings - using MultiMediaService + MultiMediaService? _multiMediaService; + List _mediaServers = []; + MediaServerConfig? _defaultMediaServer; + bool _mediaServersExpanded = false; // Track expansion state + + // Store original values to detect changes + List _originalMediaServers = []; + String? _originalDefaultServerId; + bool _originalUseNip05RelaysAutomatically = false; + bool _originalIsDarkMode = false; @override void initState() { super.initState(); + _initializeMultiMediaService(); _loadSetting(); } + Future _initializeMultiMediaService() async { + final localStorage = ServiceLocator.instance.localStorageService; + if (localStorage != null) { + _multiMediaService = MultiMediaService(localStorage: localStorage); + await _multiMediaService!.loadServers(); + + // Migrate old single server config to new format if needed + await _migrateOldMediaServerConfig(); + + setState(() { + _mediaServers = _multiMediaService!.getServers(); + _defaultMediaServer = _multiMediaService!.getDefaultServer(); + _originalMediaServers = List.from(_mediaServers); + _originalDefaultServerId = _defaultMediaServer?.id; + _mediaServersExpanded = _mediaServers.isNotEmpty; // Auto-expand if servers exist + }); + } + } + + Future _migrateOldMediaServerConfig() async { + if (_multiMediaService == null) return; + + final localStorage = ServiceLocator.instance.localStorageService; + if (localStorage == null) return; + + // Check if we already have media servers configured + if (_multiMediaService!.getServers().isNotEmpty) { + return; // Already migrated + } + + // Check for old single server config + final settingsItem = await localStorage.getItem('app_settings'); + if (settingsItem == null) return; + + final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false'; + final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com'; + + final mediaServerType = 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?; + + // Migrate Immich config + if (immichEnabled && mediaServerType == 'immich' && immichUrl != null && immichKey != null) { + final config = MediaServerConfig( + id: 'immich-${DateTime.now().millisecondsSinceEpoch}', + type: 'immich', + baseUrl: immichUrl, + apiKey: immichKey, + isDefault: true, + name: 'Immich Server', + ); + await _multiMediaService!.addServer(config); + Logger.info('Migrated Immich server config to MultiMediaService'); + } + + // Migrate Blossom config + if (mediaServerType == 'blossom' || (!immichEnabled && blossomUrl != null)) { + final url = blossomUrl ?? defaultBlossomServer; + final config = MediaServerConfig( + id: 'blossom-${DateTime.now().millisecondsSinceEpoch}', + type: 'blossom', + baseUrl: url, + isDefault: true, + name: 'Blossom Server', + ); + await _multiMediaService!.addServer(config); + Logger.info('Migrated Blossom server config to MultiMediaService'); + } + } + @override void dispose() { _urlController.dispose(); - _immichUrlController.dispose(); - _immichKeyController.dispose(); - _blossomUrlController.dispose(); super.dispose(); } @@ -61,36 +138,23 @@ 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; + + // Store original values for change detection + _originalUseNip05RelaysAutomatically = useNip05; + _originalIsDarkMode = isDark; + _hasUnsavedChanges = false; }); + // Update theme notifier if available final themeNotifier = ServiceLocator.instance.themeNotifier; if (themeNotifier != null) { @@ -98,48 +162,73 @@ class _RelayManagementScreenState extends State { } } else { setState(() { - _mediaServerType = immichEnabled ? 'immich' : 'blossom'; - _blossomBaseUrl = defaultBlossomServer; - _blossomUrlController.text = defaultBlossomServer; _isLoadingSetting = false; + _originalUseNip05RelaysAutomatically = false; + _originalIsDarkMode = false; + _hasUnsavedChanges = false; + }); + } + + // Only reload media servers from MultiMediaService if we haven't loaded them yet + // This prevents overwriting local changes + if (_multiMediaService != null && _mediaServers.isEmpty) { + await _multiMediaService!.loadServers(); + setState(() { + _mediaServers = _multiMediaService!.getServers(); + _defaultMediaServer = _multiMediaService!.getDefaultServer(); + _originalMediaServers = List.from(_mediaServers); + _originalDefaultServerId = _defaultMediaServer?.id; }); } } catch (e) { + Logger.error('Failed to load settings: $e', e); setState(() { _isLoadingSetting = false; }); } } - Future _saveSetting(String key, bool value) async { - try { - final localStorage = ServiceLocator.instance.localStorageService; - if (localStorage == null) return; - - final settingsItem = await localStorage.getItem('app_settings'); - final data = settingsItem?.data ?? {}; - data[key] = value; - - await localStorage.insertItem(Item( - id: 'app_settings', - data: data, - )); - + void _markSettingsChanged() { + // Check if any setting has changed + final mediaServersChanged = _mediaServers.length != _originalMediaServers.length || + _defaultMediaServer?.id != _originalDefaultServerId || + _mediaServers.any((s) { + final original = _originalMediaServers.firstWhere( + (os) => os.id == s.id, + orElse: () => MediaServerConfig(id: '', type: '', baseUrl: ''), + ); + return original.id.isEmpty || + original.type != s.type || + original.baseUrl != s.baseUrl || + original.apiKey != s.apiKey || + original.isDefault != s.isDefault; + }); + + final hasChanges = + _useNip05RelaysAutomatically != _originalUseNip05RelaysAutomatically || + _isDarkMode != _originalIsDarkMode || + mediaServersChanged; + + if (_hasUnsavedChanges != hasChanges) { setState(() { - if (key == 'use_nip05_relays_automatically') { - _useNip05RelaysAutomatically = value; - } else if (key == 'dark_mode') { - _isDarkMode = value; - // Notify the app to update theme - _updateAppTheme(value); - } + _hasUnsavedChanges = hasChanges; }); - } catch (e) { - // Log error but don't show to user - setting will just not persist } } - Future _saveMediaServerSettings() async { + Future _saveSetting(String key, bool value) async { + // Don't auto-save - just mark as changed + setState(() { + if (key == 'use_nip05_relays_automatically') { + _useNip05RelaysAutomatically = value; + } else if (key == 'dark_mode') { + _isDarkMode = value; + } + }); + _markSettingsChanged(); + } + + Future _saveAllSettings() async { try { final localStorage = ServiceLocator.instance.localStorageService; if (localStorage == null) return; @@ -147,31 +236,97 @@ class _RelayManagementScreenState extends State { 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; + // Save app settings + data['use_nip05_relays_automatically'] = _useNip05RelaysAutomatically; + data['dark_mode'] = _isDarkMode; await localStorage.insertItem(Item( id: 'app_settings', data: data, )); + // Save media servers via MultiMediaService + if (_multiMediaService != null) { + // Update all servers + for (final server in _mediaServers) { + final existing = _multiMediaService!.getServers().firstWhere( + (s) => s.id == server.id, + orElse: () => MediaServerConfig(id: '', type: '', baseUrl: ''), + ); + if (existing.id.isEmpty) { + // New server + await _multiMediaService!.addServer(server); + } else { + // Updated server + await _multiMediaService!.updateServer(server.id, server); + } + } + + // Remove deleted servers + for (final original in _originalMediaServers) { + if (!_mediaServers.any((s) => s.id == original.id)) { + await _multiMediaService!.removeServer(original.id); + } + } + + // Set default server + if (_defaultMediaServer != null) { + await _multiMediaService!.setDefaultServer(_defaultMediaServer!.id); + } + + await _multiMediaService!.saveServers(); + } + + // Update theme immediately + _updateAppTheme(_isDarkMode); + + // Update original values to reflect saved state + setState(() { + _originalUseNip05RelaysAutomatically = _useNip05RelaysAutomatically; + _originalIsDarkMode = _isDarkMode; + _originalMediaServers = List.from(_mediaServers); + _originalDefaultServerId = _defaultMediaServer?.id; + _hasUnsavedChanges = false; + }); + // Show success message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Media server settings saved'), + content: Text('Settings saved successfully'), backgroundColor: Colors.green, duration: Duration(seconds: 1), ), ); } + + // Reinitialize media service with new settings immediately + try { + await AppInitializer.reinitializeMediaService(); + Logger.info('Media service reinitialized with new settings'); + } catch (e) { + Logger.error('Failed to reinitialize media service: $e', e); + // Don't show error to user - settings are saved, service will be reinitialized on restart + } } catch (e) { - // Log error + Logger.error('Failed to save settings: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save settings: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 2), + ), + ); + } } } + void _updateMediaServerSettings() { + // Don't auto-save - just mark as changed + _markSettingsChanged(); + } + void _updateAppTheme(bool isDark) { // Update the theme notifier, which MyApp is listening to final themeNotifier = ServiceLocator.instance.themeNotifier; @@ -180,17 +335,148 @@ class _RelayManagementScreenState extends State { } } + Future _addMediaServer(bool immichEnabled, String defaultBlossomServer) async { + final result = await showDialog( + context: context, + builder: (context) => _MediaServerDialog( + immichEnabled: immichEnabled, + defaultBlossomServer: defaultBlossomServer, + ), + ); + + if (result != null && mounted) { + Logger.info('Adding media server: ${result.type} - ${result.baseUrl}, isDefault: ${result.isDefault}'); + + // Create a new list to ensure Flutter detects the change + final updatedServers = List.from(_mediaServers); + + // If this is the first server, make it default + if (updatedServers.isEmpty) { + updatedServers.add(result.copyWith(isDefault: true)); + } else { + // If setting as default, unset other defaults + if (result.isDefault) { + for (var i = 0; i < updatedServers.length; i++) { + if (updatedServers[i].isDefault) { + updatedServers[i] = updatedServers[i].copyWith(isDefault: false); + } + } + } + updatedServers.add(result); + } + + setState(() { + _mediaServers = updatedServers; + _defaultMediaServer = updatedServers.firstWhere( + (s) => s.isDefault, + orElse: () => updatedServers.isNotEmpty ? updatedServers.first : result, + ); + _mediaServersExpanded = true; // Expand to show the newly added server + }); + + Logger.info('Media server added. Total servers: ${_mediaServers.length}, Default: ${_defaultMediaServer?.baseUrl}'); + _updateMediaServerSettings(); + } else if (result == null) { + Logger.info('Media server dialog cancelled'); + } else { + Logger.warning('Cannot add media server - widget not mounted'); + } + } + + Future _editMediaServer(MediaServerConfig server) async { + final result = await showDialog( + context: context, + builder: (context) => _MediaServerDialog( + server: server, + immichEnabled: dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false', + defaultBlossomServer: dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com', + ), + ); + + if (result != null) { + setState(() { + final index = _mediaServers.indexWhere((s) => s.id == server.id); + if (index != -1) { + _mediaServers[index] = result; + if (result.isDefault) { + _defaultMediaServer = result; + } else if (_defaultMediaServer?.id == server.id) { + _defaultMediaServer = null; + } + } + }); + _updateMediaServerSettings(); + } + } + + Future _removeMediaServer(MediaServerConfig server) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove Media Server?'), + content: Text('Are you sure you want to remove "${server.name ?? server.type}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Remove', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() { + _mediaServers.removeWhere((s) => s.id == server.id); + if (_defaultMediaServer?.id == server.id) { + _defaultMediaServer = _mediaServers.isNotEmpty ? _mediaServers.first : null; + if (_defaultMediaServer != null) { + final index = _mediaServers.indexWhere((s) => s.id == _defaultMediaServer!.id); + if (index != -1) { + _mediaServers[index] = _defaultMediaServer!.copyWith(isDefault: true); + _defaultMediaServer = _mediaServers[index]; + } + } + } + }); + _updateMediaServerSettings(); + } + } + + void _setDefaultServer(MediaServerConfig server) { + setState(() { + // Unset current default + for (var i = 0; i < _mediaServers.length; i++) { + if (_mediaServers[i].isDefault) { + _mediaServers[i] = _mediaServers[i].copyWith(isDefault: false); + } + } + + // Set new default + final index = _mediaServers.indexWhere((s) => s.id == server.id); + if (index != -1) { + _mediaServers[index] = server.copyWith(isDefault: true); + _defaultMediaServer = _mediaServers[index]; + } + }); + _updateMediaServerSettings(); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Nostr Relay Management'), + title: const Text('Advanced Settings'), ), body: ListenableBuilder( listenable: widget.controller, builder: (context, child) { - return Column( - children: [ + return SingleChildScrollView( + child: Column( + children: [ // Settings section Container( width: double.infinity, @@ -207,12 +493,37 @@ class _RelayManagementScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Settings', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ElevatedButton.icon( + onPressed: _hasUnsavedChanges ? _saveAllSettings : null, + icon: const Icon(Icons.save, size: 18), + label: const Text('Save'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ), + ], ), + if (_hasUnsavedChanges) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + 'You have unsaved changes', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.orange, + fontStyle: FontStyle.italic, + ), + ), + ), const SizedBox(height: 12), if (_isLoadingSetting) const Row( @@ -253,100 +564,87 @@ class _RelayManagementScreenState extends State { contentPadding: EdgeInsets.zero, ), const SizedBox(height: 16), - // Media Server Settings + // Media Server Settings - Multiple Servers Builder( builder: (context) { - // Read Immich enabled status from env final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false'; + final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com'; return ExpansionTile( - title: const Text('Media Server'), - subtitle: Text(_mediaServerType == 'immich' ? 'Immich' : 'Blossom'), - initiallyExpanded: false, + key: ValueKey('media-servers-${_mediaServers.length}-${_mediaServersExpanded}'), + title: const Text('Media Servers'), + subtitle: Text(_mediaServers.isEmpty + ? 'No servers configured' + : '${_mediaServers.length} server(s)${_defaultMediaServer != null ? " • Default: ${_defaultMediaServer!.name ?? _defaultMediaServer!.type}" : ""}'), + initiallyExpanded: _mediaServersExpanded, + onExpansionChanged: (expanded) { + setState(() { + _mediaServersExpanded = expanded; + }); + }, 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) ...[ + // Server list + if (_mediaServers.isEmpty) 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(), + child: Text( + 'No media servers configured. Add one to get started.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + ) + else + ...List.generate(_mediaServers.length, (index) { + final server = _mediaServers[index]; + return ListTile( + key: ValueKey(server.id), + leading: Icon( + server.isDefault ? Icons.star : Icons.star_border, + color: server.isDefault ? Colors.amber : Colors.grey, + ), + title: Text(server.name ?? '${server.type.toUpperCase()} Server'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(server.baseUrl), + if (server.isDefault) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Chip( + label: const Text('Default'), + labelStyle: const TextStyle(fontSize: 10), + padding: EdgeInsets.zero, + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _editMediaServer(server), ), - onChanged: (value) { - _immichBaseUrl = value.isEmpty ? null : value; - _saveMediaServerSettings(); - }, - ), - const SizedBox(height: 16), - TextField( - controller: _immichKeyController, - decoration: const InputDecoration( - labelText: 'Immich API Key', - border: OutlineInputBorder(), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _removeMediaServer(server), ), - 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(); - }, - ), + onTap: () => _setDefaultServer(server), + ); + }), + const Divider(), + // Add server button + Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton.icon( + onPressed: () => _addMediaServer(immichEnabled, defaultBlossomServer), + icon: const Icon(Icons.add), + label: const Text('Add Media Server'), ), - ], + ), ], ); }, @@ -379,157 +677,170 @@ class _RelayManagementScreenState extends State { ), ), - // Top action buttons - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Test All and Toggle All buttons - Row( + // Relay Management Section (Expandable) + ExpansionTile( + title: const Text('Nostr Relays'), + subtitle: Text('${widget.controller.relays.length} relay(s) configured'), + initiallyExpanded: false, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: widget.controller.isCheckingHealth - ? null - : widget.controller.checkRelayHealth, - icon: widget.controller.isCheckingHealth - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.network_check), - label: const Text('Test All'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - onPressed: widget.controller.relays.isEmpty - ? null - : () async { - await widget.controller.toggleAllRelays(); - }, - icon: const Icon(Icons.power_settings_new), - label: Text( - widget.controller.relays.isNotEmpty && - widget.controller.relays.every((r) => r.isEnabled) - ? 'Turn All Off' - : 'Turn All On', + // Test All and Toggle All buttons + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: widget.controller.isCheckingHealth + ? null + : widget.controller.checkRelayHealth, + icon: widget.controller.isCheckingHealth + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.network_check), + label: const Text('Test All'), + ), ), - ), - ), - ], - ), - const SizedBox(height: 16), - // Add relay input - Row( - children: [ - Expanded( - child: TextField( - controller: _urlController, - decoration: InputDecoration( - labelText: 'Relay URL', - hintText: 'wss://relay.example.com', - border: const OutlineInputBorder(), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: widget.controller.relays.isEmpty + ? null + : () async { + final hasEnabled = widget.controller.relays.any((r) => r.isEnabled); + if (hasEnabled) { + await widget.controller.turnAllOff(); + } else { + await widget.controller.turnAllOn(); + } + }, + icon: const Icon(Icons.power_settings_new), + label: Text( + widget.controller.relays.isNotEmpty && + widget.controller.relays.any((r) => r.isEnabled) + ? 'Turn All Off' + : 'Turn All On', + ), + ), ), - keyboardType: TextInputType.url, - ), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: () async { - final url = _urlController.text.trim(); - if (url.isNotEmpty) { - final success = await widget.controller.addRelay(url); - if (mounted) { - if (success) { - _urlController.clear(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Relay added and connected successfully'), - backgroundColor: Colors.green, - duration: Duration(seconds: 1), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - widget.controller.error ?? 'Failed to connect to relay', - ), - backgroundColor: Colors.orange, - duration: const Duration(seconds: 3), - ), - ); - } - } - } - }, - icon: const Icon(Icons.add), - label: const Text('Add'), + ], ), - ], - ), - ], - ), - ), - - const Divider(), - - // Relay list - Expanded( - child: widget.controller.relays.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + const SizedBox(height: 16), + // Add relay input + Row( children: [ - Icon( - Icons.cloud_off, - size: 64, - color: Colors.grey.shade400, - ), - const SizedBox(height: 16), - Text( - 'No relays configured', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.grey, - ), + Expanded( + child: TextField( + controller: _urlController, + decoration: InputDecoration( + labelText: 'Relay URL', + hintText: 'wss://relay.example.com', + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + ), ), - const SizedBox(height: 8), - Text( - 'Add a relay to get started', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: () async { + final url = _urlController.text.trim(); + if (url.isNotEmpty) { + final success = await widget.controller.addRelay(url); + if (mounted) { + if (success) { + _urlController.clear(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Relay added and connected successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + widget.controller.error ?? 'Failed to connect to relay', + ), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + }, + icon: const Icon(Icons.add), + label: const Text('Add'), ), ], ), - ) - : ListView.builder( - itemCount: widget.controller.relays.length, - itemBuilder: (context, index) { - final relay = widget.controller.relays[index]; - return _RelayListItem( - relay: relay, - onToggle: () async { - await widget.controller.toggleRelay(relay.url); - }, - onRemove: () { - widget.controller.removeRelay(relay.url); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Relay ${relay.url} removed'), - duration: const Duration(seconds: 2), + const SizedBox(height: 16), + // Relay list + SizedBox( + height: 300, + child: widget.controller.relays.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off, + size: 48, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'No relays configured', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + 'Add a relay to get started', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: widget.controller.relays.length, + itemBuilder: (context, index) { + final relay = widget.controller.relays[index]; + return _RelayListItem( + relay: relay, + onToggle: () async { + await widget.controller.toggleRelay(relay.url); + }, + onRemove: () { + widget.controller.removeRelay(relay.url); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Relay ${relay.url} removed'), + duration: const Duration(seconds: 2), + ), + ); + }, + ); + }, ), - ); - }, - ); - }, - ), + ), + ], + ), + ), + ], ), ], + ), ); }, ), @@ -648,3 +959,159 @@ class _RelayListItem extends StatelessWidget { ); } } + +/// Dialog for adding/editing a media server configuration. +class _MediaServerDialog extends StatefulWidget { + final MediaServerConfig? server; + final bool immichEnabled; + final String defaultBlossomServer; + + const _MediaServerDialog({ + this.server, + required this.immichEnabled, + required this.defaultBlossomServer, + }); + + @override + State<_MediaServerDialog> createState() => _MediaServerDialogState(); +} + +class _MediaServerDialogState extends State<_MediaServerDialog> { + late String _serverType; + late TextEditingController _nameController; + late TextEditingController _urlController; + late TextEditingController _apiKeyController; + bool _isDefault = false; + + @override + void initState() { + super.initState(); + _serverType = widget.server?.type ?? (widget.immichEnabled ? 'immich' : 'blossom'); + _nameController = TextEditingController(text: widget.server?.name ?? ''); + _urlController = TextEditingController(text: widget.server?.baseUrl ?? widget.defaultBlossomServer); + _apiKeyController = TextEditingController(text: widget.server?.apiKey ?? ''); + _isDefault = widget.server?.isDefault ?? false; + } + + @override + void dispose() { + _nameController.dispose(); + _urlController.dispose(); + _apiKeyController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.server == null ? 'Add Media Server' : 'Edit Media Server'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Server type selection + if (widget.immichEnabled) + RadioListTile( + title: const Text('Immich'), + subtitle: const Text('Requires API key and URL'), + value: 'immich', + groupValue: _serverType, + onChanged: (value) => setState(() => _serverType = value!), + ), + RadioListTile( + title: const Text('Blossom'), + subtitle: const Text('Requires URL only (uses Nostr auth)'), + value: 'blossom', + groupValue: _serverType, + onChanged: (value) => setState(() => _serverType = value!), + ), + const SizedBox(height: 16), + // Name field + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name (optional)', + hintText: 'My Media Server', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + // URL field + TextField( + controller: _urlController, + decoration: InputDecoration( + labelText: '${_serverType == 'immich' ? 'Immich' : 'Blossom'} Base URL', + hintText: _serverType == 'immich' + ? 'https://immich.example.com' + : 'https://blossom.example.com', + border: const OutlineInputBorder(), + ), + ), + // API key field (Immich only) + if (_serverType == 'immich') ...[ + const SizedBox(height: 16), + TextField( + controller: _apiKeyController, + decoration: const InputDecoration( + labelText: 'Immich API Key', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + ], + const SizedBox(height: 16), + // Default checkbox + CheckboxListTile( + title: const Text('Set as default server'), + subtitle: const Text('Uploads will try this server first'), + value: _isDefault, + onChanged: (value) => setState(() => _isDefault = value ?? false), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final url = _urlController.text.trim(); + if (url.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('URL is required')), + ); + return; + } + + if (_serverType == 'immich' && _apiKeyController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('API key is required for Immich')), + ); + return; + } + + final config = MediaServerConfig( + id: widget.server?.id ?? '${_serverType}-${DateTime.now().millisecondsSinceEpoch}', + type: _serverType, + baseUrl: url, + apiKey: _serverType == 'immich' ? _apiKeyController.text.trim() : null, + isDefault: _isDefault, + name: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(), + ); + + Logger.info('Dialog Save button pressed. Config: ${config.type} - ${config.baseUrl}, isDefault: ${config.isDefault}'); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(config); + } else { + Logger.error('Cannot pop dialog - Navigator stack issue'); + } + }, + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/ui/session/session_screen.dart b/lib/ui/session/session_screen.dart index 16785c0..dca2cfb 100644 --- a/lib/ui/session/session_screen.dart +++ b/lib/ui/session/session_screen.dart @@ -429,8 +429,8 @@ class _SessionScreenState extends State { children: [ _buildProfilePicture( currentUser.nostrProfile?.picture, - radius: 30, - ), + radius: 30, + ), const SizedBox(width: 12), Expanded( child: Column( @@ -443,11 +443,11 @@ class _SessionScreenState extends State { currentUser.nostrProfile?.name ?? currentUser.nostrProfile?.displayName ?? currentUser.username, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), ), // Show edit icon if user has private key (can publish updates) if (currentUser.nostrPrivateKey != null) @@ -916,28 +916,28 @@ class _Nip05SectionState extends State<_Nip05Section> { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ..._preferredRelays.map((relay) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - Icon( - Icons.link, - size: 14, - color: Colors.grey[600], - ), - const SizedBox(width: 8), - Expanded( - child: Text( - relay, - style: TextStyle( - fontSize: 12, - color: Colors.grey[700], - ), - ), + ..._preferredRelays.map((relay) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Icon( + Icons.link, + size: 14, + color: Colors.grey[600], + ), + const SizedBox(width: 8), + Expanded( + child: Text( + relay, + style: TextStyle( + fontSize: 12, + color: Colors.grey[700], ), - ], + ), ), - )), + ], + ), + )), const SizedBox(height: 12), Text( 'Note: You can enable automatic relay replacement in Relay Management settings', diff --git a/test/ui/navigation/main_navigation_scaffold_test.dart b/test/ui/navigation/main_navigation_scaffold_test.dart index d28d2e9..f511eb9 100644 --- a/test/ui/navigation/main_navigation_scaffold_test.dart +++ b/test/ui/navigation/main_navigation_scaffold_test.dart @@ -230,7 +230,7 @@ void main() { when(mockSessionService.isLoggedIn).thenReturn(true); final testUser = User(id: 'test_user', username: 'Test User'); when(mockSessionService.currentUser).thenReturn(testUser); - + await tester.pumpWidget(createTestWidget()); await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow widget to build @@ -251,7 +251,7 @@ void main() { when(mockSessionService.isLoggedIn).thenReturn(true); final testUser = User(id: 'test_user', username: 'Test User'); when(mockSessionService.currentUser).thenReturn(testUser); - + await tester.pumpWidget(createTestWidget()); await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow widget to build diff --git a/test/ui/relay_management/relay_management_screen_test.dart b/test/ui/relay_management/relay_management_screen_test.dart index ebfe1c7..5a2ef25 100644 --- a/test/ui/relay_management/relay_management_screen_test.dart +++ b/test/ui/relay_management/relay_management_screen_test.dart @@ -121,8 +121,8 @@ void main() { // If controller hasn't reloaded, the test might fail, but that's a controller issue // Let's check if controller has the relays (it might have reloaded via ListenableBuilder) if (controller.relays.length >= 2) { - expect(find.textContaining('wss://relay1.example.com'), findsWidgets); - expect(find.textContaining('wss://relay2.example.com'), findsWidgets); + expect(find.textContaining('wss://relay1.example.com'), findsWidgets); + expect(find.textContaining('wss://relay2.example.com'), findsWidgets); // Verify we have relay list items final relayCards = find.byType(Card); expect(relayCards, findsAtLeastNWidgets(controller.relays.length)); @@ -301,7 +301,7 @@ void main() { await tester.tap(closeButtons.first); await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow state update - + // After closing, error should be cleared from controller expect(controller.error, isNull, reason: 'Error should be cleared after tapping close'); await tester.pump(const Duration(milliseconds: 100));