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(); final FocusNode _urlFocusNode = FocusNode(); 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; // Store original values to detect changes List _originalMediaServers = []; String? _originalDefaultServerId; bool _originalUseNip05RelaysAutomatically = false; bool _originalIsDarkMode = false; @override void initState() { super.initState(); _initializeMultiMediaService(); _loadSetting(); _urlFocusNode.addListener(_onUrlFocusChanged); } void _onUrlFocusChanged() { if (_urlFocusNode.hasFocus && _urlController.text.isEmpty) { _urlController.text = 'wss://'; _urlController.selection = TextSelection.fromPosition( TextPosition(offset: _urlController.text.length), ); } } 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; }); } } 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(); _urlFocusNode.removeListener(_onUrlFocusChanged); _urlFocusNode.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, ); // Server added successfully }); 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) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: const Text('Advanced Settings'), actions: _hasUnsavedChanges ? [ TextButton.icon( onPressed: _saveAllSettings, icon: const Icon(Icons.save, size: 18), label: const Text('Save'), style: TextButton.styleFrom( foregroundColor: Colors.green, ), ), ] : null, ), body: ListenableBuilder( listenable: widget.controller, builder: (context, child) { return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Preferences Section _buildSectionCard( context: context, title: 'Preferences', icon: Icons.settings_outlined, child: _isLoadingSetting ? const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ) : Column( children: [ _buildSettingTile( context: context, title: 'Dark Mode', subtitle: 'Enable dark theme for the app', value: _isDarkMode, onChanged: (value) { _saveSetting('dark_mode', value); }, icon: Icons.dark_mode_outlined, ), ], ), ), const SizedBox(height: 8), // Media Server Section Builder( builder: (context) { final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false'; final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com'; return _buildSectionCard( context: context, title: 'Media Server', icon: Icons.storage_outlined, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_defaultMediaServer != null) _buildMediaServerTile( context: context, server: _defaultMediaServer!, isDefault: true, onEdit: () => _editMediaServer(_defaultMediaServer!), onRemove: () => _removeMediaServer(_defaultMediaServer!), ) else Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( 'No media server configured', style: theme.textTheme.bodySmall?.copyWith( color: Colors.grey, fontSize: 12, ), ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: () => _addMediaServer(immichEnabled, defaultBlossomServer), icon: const Icon(Icons.add, size: 16), label: const Text('Add More', style: TextStyle(fontSize: 13)), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), minimumSize: const Size(0, 36), ), ), ], ), ); }, ), const SizedBox(height: 8), // Relays Section _buildSectionCard( context: context, title: 'Relays', icon: Icons.cloud_outlined, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Add relay input TextField( controller: _urlController, focusNode: _urlFocusNode, style: const TextStyle(fontSize: 14), decoration: InputDecoration( labelText: 'Relay URL', hintText: 'wss://relay.example.com', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.link, size: 18), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), isDense: true, ), keyboardType: TextInputType.url, ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: () async { String url = _urlController.text.trim(); // Remove wss:// prefix if user added it manually, we'll add it properly if (url.startsWith('wss://')) { url = url.substring(6); } if (url.isNotEmpty) { final fullUrl = url.startsWith('wss://') ? url : 'wss://$url'; final success = await widget.controller.addRelay(fullUrl); 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, size: 16), label: const Text('Add Relay', style: TextStyle(fontSize: 13)), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), minimumSize: const Size(0, 36), ), ), const SizedBox(height: 12), // Relay list if (widget.controller.relays.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Column( children: [ Icon( Icons.cloud_off, size: 36, color: Colors.grey.shade400, ), const SizedBox(height: 8), Text( 'No relays configured', style: theme.textTheme.bodyMedium?.copyWith( color: Colors.grey, fontSize: 13, ), ), const SizedBox(height: 4), Text( 'Add a relay to get started', style: theme.textTheme.bodySmall?.copyWith( color: Colors.grey, fontSize: 11, ), ), ], ), ) else ...widget.controller.relays.map((relay) { return Padding( padding: const EdgeInsets.only(bottom: 6), child: _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), ), ); }, ), ); }).toList(), ], ), ), // Error message if (widget.controller.error != null) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.red.shade200), ), child: Row( children: [ Icon(Icons.error_outline, size: 16, color: Colors.red.shade700), const SizedBox(width: 8), Expanded( child: Text( widget.controller.error!, style: TextStyle( color: Colors.red.shade700, fontSize: 12, ), ), ), IconButton( icon: const Icon(Icons.close, size: 16), onPressed: widget.controller.clearError, color: Colors.red.shade700, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), ), ], ), ), ], ], ), ); }, ), ); } Widget _buildSectionCard({ required BuildContext context, required String title, required IconData icon, required Widget child, }) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Card( elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide( color: isDark ? Colors.grey.shade800 : Colors.grey.shade200, width: 1, ), ), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: theme.primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), ), child: Icon(icon, color: theme.primaryColor, size: 16), ), const SizedBox(width: 8), Text( title, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, fontSize: 15, ), ), ], ), const SizedBox(height: 12), child, ], ), ), ); } Widget _buildSettingTile({ required BuildContext context, required String title, required String subtitle, required bool value, required ValueChanged onChanged, required IconData icon, }) { final theme = Theme.of(context); return Row( children: [ Icon(icon, size: 16, color: theme.primaryColor), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, fontSize: 14, ), ), const SizedBox(height: 2), Text( subtitle, style: theme.textTheme.bodySmall?.copyWith( color: Colors.grey, fontSize: 11, ), ), ], ), ), Transform.scale( scale: 0.85, child: Switch( value: value, onChanged: onChanged, ), ), ], ); } Widget _buildMediaServerTile({ required BuildContext context, required MediaServerConfig server, required bool isDefault, required VoidCallback onEdit, required VoidCallback onRemove, }) { final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(8), border: Border.all( color: isDefault ? theme.primaryColor.withValues(alpha: 0.3) : Colors.grey.shade300, width: isDefault ? 1.5 : 1, ), ), child: Row( children: [ Container( padding: const EdgeInsets.all(5), decoration: BoxDecoration( color: theme.primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), ), child: Icon( Icons.storage, color: theme.primaryColor, size: 16, ), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( server.name ?? '${server.type.toUpperCase()} Server', style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, fontSize: 13, ), overflow: TextOverflow.ellipsis, ), ), if (isDefault) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: theme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(3), ), child: Text( 'Default', style: TextStyle( fontSize: 9, fontWeight: FontWeight.w600, color: theme.primaryColor, ), ), ), ], ], ), const SizedBox(height: 2), Text( server.baseUrl, style: theme.textTheme.bodySmall?.copyWith( color: Colors.grey, fontSize: 11, ), overflow: TextOverflow.ellipsis, ), ], ), ), IconButton( icon: const Icon(Icons.edit, size: 16), onPressed: onEdit, tooltip: 'Edit', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), IconButton( icon: const Icon(Icons.delete_outline, size: 16), onPressed: onRemove, tooltip: 'Remove', color: Colors.red, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), ], ), ); } } /// 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) { final theme = Theme.of(context); final isConnected = relay.isConnected && relay.isEnabled; return Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(8), border: Border.all( color: isConnected ? Colors.green.withValues(alpha: 0.3) : Colors.grey.shade300, width: isConnected ? 1.5 : 1, ), ), child: Row( children: [ // Status indicator Container( width: 10, height: 10, decoration: BoxDecoration( shape: BoxShape.circle, color: isConnected ? Colors.green : Colors.grey, boxShadow: isConnected ? [ BoxShadow( color: Colors.green.withValues(alpha: 0.5), blurRadius: 3, spreadRadius: 1, ), ] : null, ), ), const SizedBox(width: 10), // URL and status text Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( relay.url, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, fontSize: 13, ), overflow: TextOverflow.ellipsis, maxLines: 1, ), const SizedBox(height: 2), Row( children: [ Icon( isConnected ? Icons.check_circle : Icons.cancel, size: 12, color: isConnected ? Colors.green : Colors.grey, ), const SizedBox(width: 3), Text( isConnected ? 'Connected' : 'Disabled', style: theme.textTheme.bodySmall?.copyWith( color: isConnected ? Colors.green : Colors.grey, fontWeight: FontWeight.w500, fontSize: 11, ), ), ], ), ], ), ), const SizedBox(width: 8), // Toggle switch Transform.scale( scale: 0.85, child: Switch( value: relay.isEnabled, onChanged: (_) => onToggle(), ), ), const SizedBox(width: 4), // Remove button IconButton( icon: const Icon(Icons.delete_outline, size: 16), color: Colors.red, tooltip: 'Remove', onPressed: onRemove, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 28, minHeight: 28, ), ), ], ), ); } } /// 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'), ), ], ); } }