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'; 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. /// /// Allows users to view, add, remove, test, and toggle relays. class RelayManagementScreen extends StatefulWidget { /// Controller for managing relay state. final RelayManagementController controller; /// Creates a [RelayManagementScreen] instance. const RelayManagementScreen({ super.key, required this.controller, }); @override State createState() => _RelayManagementScreenState(); } class _RelayManagementScreenState extends State { final TextEditingController _urlController = TextEditingController(); bool _useNip05RelaysAutomatically = false; bool _isDarkMode = false; bool _isLoadingSetting = true; bool _hasUnsavedChanges = false; // Track if settings have been modified // Media server settings - using MultiMediaService MultiMediaService? _multiMediaService; List _mediaServers = []; MediaServerConfig? _defaultMediaServer; bool _mediaServersExpanded = false; bool _settingsExpanded = true; // Settings expanded by default // 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(); if (!mounted) return; // Migrate old single server config to new format if needed await _migrateOldMediaServerConfig(); if (!mounted) return; 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; if (!mounted) 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 (!mounted) return; 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(); super.dispose(); } Future _loadSetting() async { try { final localStorage = ServiceLocator.instance.localStorageService; if (localStorage == null) { if (mounted) { setState(() { _isLoadingSetting = false; }); } return; } if (!mounted) return; final settingsItem = await localStorage.getItem('app_settings'); if (!mounted) return; if (settingsItem != null) { final useNip05 = settingsItem.data['use_nip05_relays_automatically'] == true; final isDark = settingsItem.data['dark_mode'] == true; if (mounted) { setState(() { _useNip05RelaysAutomatically = useNip05; _isDarkMode = isDark; _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) { themeNotifier.setThemeMode(isDark ? ThemeMode.dark : ThemeMode.light); } } else { if (mounted) { setState(() { _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(); if (mounted) { setState(() { _mediaServers = _multiMediaService!.getServers(); _defaultMediaServer = _multiMediaService!.getDefaultServer(); _originalMediaServers = List.from(_mediaServers); _originalDefaultServerId = _defaultMediaServer?.id; }); } } } catch (e) { // Database might be closed (e.g., during tests) - handle gracefully Logger.error('Failed to load settings: $e', e); if (mounted) { setState(() { _isLoadingSetting = false; // Set defaults if loading failed _useNip05RelaysAutomatically = false; _isDarkMode = false; _originalUseNip05RelaysAutomatically = false; _originalIsDarkMode = false; _hasUnsavedChanges = false; }); } } } 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(() { _hasUnsavedChanges = hasChanges; }); } } 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; final settingsItem = await localStorage.getItem('app_settings'); final data = settingsItem?.data ?? {}; // 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('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) { 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; if (themeNotifier != null) { themeNotifier.setThemeMode(isDark ? ThemeMode.dark : ThemeMode.light); } } 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('Advanced Settings'), ), body: ListenableBuilder( listenable: widget.controller, builder: (context, child) { return SingleChildScrollView( child: Column( children: [ // Settings section - Now using ExpansionTile for uniformity ExpansionTile( title: const Text('Settings'), subtitle: _hasUnsavedChanges ? const Text('You have unsaved changes', style: TextStyle(color: Colors.orange)) : Text('${_useNip05RelaysAutomatically ? "NIP-05" : "Manual"} relays • ${_isDarkMode ? "Dark" : "Light"} mode'), initiallyExpanded: _settingsExpanded, trailing: _hasUnsavedChanges ? ElevatedButton.icon( onPressed: _saveAllSettings, icon: const Icon(Icons.save, size: 18), label: const Text('Save'), style: ElevatedButton.styleFrom( backgroundColor: Colors.green, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), ) : null, onExpansionChanged: (expanded) { setState(() { _settingsExpanded = expanded; }); }, children: [ Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_isLoadingSetting) const Row( children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 12), Text('Loading settings...'), ], ) else ...[ SwitchListTile( title: const Text('Use NIP-05 relays automatically'), subtitle: const Text( 'Automatically replace relays with NIP-05 preferred relays upon login', style: TextStyle(fontSize: 12), ), value: _useNip05RelaysAutomatically, onChanged: (value) { _saveSetting('use_nip05_relays_automatically', value); }, contentPadding: EdgeInsets.zero, ), const SizedBox(height: 8), SwitchListTile( title: const Text('Dark Mode'), subtitle: const Text( 'Enable dark theme for the app', style: TextStyle(fontSize: 12), ), value: _isDarkMode, onChanged: (value) { _saveSetting('dark_mode', value); }, contentPadding: EdgeInsets.zero, ), const SizedBox(height: 16), // Media Server Settings - Multiple Servers Builder( builder: (context) { final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false'; final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com'; return ExpansionTile( 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: [ // Server list if (_mediaServers.isEmpty) Padding( padding: const EdgeInsets.all(16.0), 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), ), IconButton( icon: const Icon(Icons.delete), onPressed: () => _removeMediaServer(server), ), ], ), onTap: () => _setDefaultServer(server), ); }), // 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'), ), ), ], ); }, ), ], ], ), ), ], ), // Error message if (widget.controller.error != null) Container( width: double.infinity, padding: const EdgeInsets.all(16), color: Colors.red.shade100, child: Row( children: [ Icon(Icons.error, color: Colors.red.shade700), const SizedBox(width: 8), Expanded( child: Text( widget.controller.error!, style: TextStyle(color: Colors.red.shade700), ), ), IconButton( icon: const Icon(Icons.close), onPressed: widget.controller.clearError, color: Colors.red.shade700, ), ], ), ), // 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: [ // 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(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', ), ), ), ], ), 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(), ), 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 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), ), ); }, ); }, ), ), ], ), ), ], ), ], ), ); }, ), ); } } /// Widget for displaying a single relay in the list. class _RelayListItem extends StatelessWidget { /// The relay to display. final NostrRelay relay; /// Callback when toggle is pressed. final VoidCallback onToggle; /// Callback when remove is pressed. final VoidCallback onRemove; const _RelayListItem({ required this.relay, required this.onToggle, required this.onRemove, }); @override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ // Status indicator Container( width: 10, height: 10, decoration: BoxDecoration( shape: BoxShape.circle, color: relay.isConnected && relay.isEnabled ? Colors.green : Colors.grey, ), ), const SizedBox(width: 8), // URL and status text Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( relay.url, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, maxLines: 1, ), const SizedBox(height: 2), Text( relay.isConnected && relay.isEnabled ? 'Connected' : 'Disabled', style: TextStyle( fontSize: 11, color: relay.isConnected && relay.isEnabled ? Colors.green : Colors.grey, ), ), ], ), ), const SizedBox(width: 8), // Toggle switch Row( mainAxisSize: MainAxisSize.min, children: [ Text( relay.isEnabled ? 'ON' : 'OFF', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: relay.isEnabled ? Colors.green : Colors.grey[600], ), ), const SizedBox(width: 4), Transform.scale( scale: 0.8, child: Switch( value: relay.isEnabled, onChanged: (_) => onToggle(), ), ), ], ), const SizedBox(width: 4), // Remove button IconButton( icon: const Icon(Icons.delete, size: 18), color: Colors.red, tooltip: 'Remove', onPressed: onRemove, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, minHeight: 32, ), ), ], ), ), ); } } /// 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 // Note: RadioListTile groupValue/onChanged are deprecated in favor of RadioGroup, // but RadioGroup is not yet available in stable Flutter. This will be updated when available. if (widget.immichEnabled) RadioListTile( title: const Text('Immich'), subtitle: const Text('Requires API key and URL'), value: 'immich', // ignore: deprecated_member_use groupValue: _serverType, // ignore: deprecated_member_use onChanged: (value) => setState(() => _serverType = value!), ), RadioListTile( title: const Text('Blossom'), subtitle: const Text('Requires URL only (uses Nostr auth)'), value: 'blossom', // ignore: deprecated_member_use groupValue: _serverType, // ignore: deprecated_member_use 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'), ), ], ); } }