|
|
|
|
@ -28,6 +28,7 @@ class RelayManagementScreen extends StatefulWidget {
|
|
|
|
|
|
|
|
|
|
class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
final TextEditingController _urlController = TextEditingController();
|
|
|
|
|
final FocusNode _urlFocusNode = FocusNode();
|
|
|
|
|
bool _useNip05RelaysAutomatically = false;
|
|
|
|
|
bool _isDarkMode = false;
|
|
|
|
|
bool _isLoadingSetting = true;
|
|
|
|
|
@ -37,8 +38,6 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
MultiMediaService? _multiMediaService;
|
|
|
|
|
List<MediaServerConfig> _mediaServers = [];
|
|
|
|
|
MediaServerConfig? _defaultMediaServer;
|
|
|
|
|
bool _mediaServersExpanded = false;
|
|
|
|
|
bool _settingsExpanded = true; // Settings expanded by default
|
|
|
|
|
|
|
|
|
|
// Store original values to detect changes
|
|
|
|
|
List<MediaServerConfig> _originalMediaServers = [];
|
|
|
|
|
@ -51,6 +50,16 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
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<void> _initializeMultiMediaService() async {
|
|
|
|
|
@ -71,7 +80,6 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
_defaultMediaServer = _multiMediaService!.getDefaultServer();
|
|
|
|
|
_originalMediaServers = List.from(_mediaServers);
|
|
|
|
|
_originalDefaultServerId = _defaultMediaServer?.id;
|
|
|
|
|
_mediaServersExpanded = _mediaServers.isNotEmpty; // Auto-expand if servers exist
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -133,6 +141,8 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_urlController.dispose();
|
|
|
|
|
_urlFocusNode.removeListener(_onUrlFocusChanged);
|
|
|
|
|
_urlFocusNode.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -399,7 +409,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
(s) => s.isDefault,
|
|
|
|
|
orElse: () => updatedServers.isNotEmpty ? updatedServers.first : result,
|
|
|
|
|
);
|
|
|
|
|
_mediaServersExpanded = true; // Expand to show the newly added server
|
|
|
|
|
// Server added successfully
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Logger.info('Media server added. Total servers: ${_mediaServers.length}, Default: ${_defaultMediaServer?.baseUrl}');
|
|
|
|
|
@ -495,274 +505,142 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
|
|
|
|
|
@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(
|
|
|
|
|
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(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: 16,
|
|
|
|
|
height: 16,
|
|
|
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
|
|
|
),
|
|
|
|
|
SizedBox(width: 12),
|
|
|
|
|
Text('Loading settings...'),
|
|
|
|
|
],
|
|
|
|
|
// 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)),
|
|
|
|
|
)
|
|
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
: Column(
|
|
|
|
|
children: [
|
|
|
|
|
_buildSettingTile(
|
|
|
|
|
context: context,
|
|
|
|
|
title: 'Dark Mode',
|
|
|
|
|
subtitle: 'Enable dark theme for the app',
|
|
|
|
|
value: _isDarkMode,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
_saveSetting('dark_mode', value);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
icon: Icons.dark_mode_outlined,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
// Media Server Settings - Multiple Servers
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
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 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;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
return _buildSectionCard(
|
|
|
|
|
context: context,
|
|
|
|
|
title: 'Media Server',
|
|
|
|
|
icon: Icons.storage_outlined,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (_defaultMediaServer != null)
|
|
|
|
|
_buildMediaServerTile(
|
|
|
|
|
context: context,
|
|
|
|
|
server: _defaultMediaServer!,
|
|
|
|
|
isDefault: true,
|
|
|
|
|
onEdit: () => _editMediaServer(_defaultMediaServer!),
|
|
|
|
|
onRemove: () => _removeMediaServer(_defaultMediaServer!),
|
|
|
|
|
)
|
|
|
|
|
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),
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
|
|
|
child: Text(
|
|
|
|
|
'No media server configured',
|
|
|
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
color: Colors.grey,
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
),
|
|
|
|
|
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(
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
OutlinedButton.icon(
|
|
|
|
|
onPressed: () => _addMediaServer(immichEnabled, defaultBlossomServer),
|
|
|
|
|
icon: const Icon(Icons.add),
|
|
|
|
|
label: const Text('Add Media Server'),
|
|
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
// 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,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
|
|
|
|
// 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),
|
|
|
|
|
// Relays Section
|
|
|
|
|
_buildSectionCard(
|
|
|
|
|
context: context,
|
|
|
|
|
title: 'Relays',
|
|
|
|
|
icon: Icons.cloud_outlined,
|
|
|
|
|
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(
|
|
|
|
|
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(width: 8),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
ElevatedButton.icon(
|
|
|
|
|
onPressed: () async {
|
|
|
|
|
final url = _urlController.text.trim();
|
|
|
|
|
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 success = await widget.controller.addRelay(url);
|
|
|
|
|
final fullUrl = url.startsWith('wss://') ? url : 'wss://$url';
|
|
|
|
|
final success = await widget.controller.addRelay(fullUrl);
|
|
|
|
|
if (mounted) {
|
|
|
|
|
if (success) {
|
|
|
|
|
_urlController.clear();
|
|
|
|
|
@ -787,48 +665,49 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
icon: const Icon(Icons.add),
|
|
|
|
|
label: const Text('Add'),
|
|
|
|
|
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: 16),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
// Relay list
|
|
|
|
|
SizedBox(
|
|
|
|
|
height: 300,
|
|
|
|
|
child: widget.controller.relays.isEmpty
|
|
|
|
|
? Center(
|
|
|
|
|
if (widget.controller.relays.isEmpty)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
Icons.cloud_off,
|
|
|
|
|
size: 48,
|
|
|
|
|
size: 36,
|
|
|
|
|
color: Colors.grey.shade400,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
'No relays configured',
|
|
|
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
|
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
|
|
|
color: Colors.grey,
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
Text(
|
|
|
|
|
'Add a relay to get started',
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
|
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
color: Colors.grey,
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: ListView.builder(
|
|
|
|
|
shrinkWrap: true,
|
|
|
|
|
itemCount: widget.controller.relays.length,
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final relay = widget.controller.relays[index];
|
|
|
|
|
return _RelayListItem(
|
|
|
|
|
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);
|
|
|
|
|
@ -842,15 +721,47 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
}).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),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
@ -858,6 +769,207 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<bool> 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.
|
|
|
|
|
@ -879,10 +991,21 @@ class _RelayListItem extends StatelessWidget {
|
|
|
|
|
|
|
|
|
|
@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),
|
|
|
|
|
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
|
|
|
|
|
@ -891,12 +1014,19 @@ class _RelayListItem extends StatelessWidget {
|
|
|
|
|
height: 10,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
color: relay.isConnected && relay.isEnabled
|
|
|
|
|
? Colors.green
|
|
|
|
|
: Colors.grey,
|
|
|
|
|
color: isConnected ? Colors.green : Colors.grey,
|
|
|
|
|
boxShadow: isConnected
|
|
|
|
|
? [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.green.withValues(alpha: 0.5),
|
|
|
|
|
blurRadius: 3,
|
|
|
|
|
spreadRadius: 1,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
: null,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
// URL and status text
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
@ -905,69 +1035,59 @@ class _RelayListItem extends StatelessWidget {
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
relay.url,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
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(
|
|
|
|
|
relay.isConnected && relay.isEnabled
|
|
|
|
|
? 'Connected'
|
|
|
|
|
: 'Disabled',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
isConnected ? 'Connected' : 'Disabled',
|
|
|
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
color: isConnected ? Colors.green : Colors.grey,
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
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,
|
|
|
|
|
scale: 0.85,
|
|
|
|
|
child: Switch(
|
|
|
|
|
value: relay.isEnabled,
|
|
|
|
|
onChanged: (_) => onToggle(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
// Remove button
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: const Icon(Icons.delete, size: 18),
|
|
|
|
|
icon: const Icon(Icons.delete_outline, size: 16),
|
|
|
|
|
color: Colors.red,
|
|
|
|
|
tooltip: 'Remove',
|
|
|
|
|
onPressed: onRemove,
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
constraints: const BoxConstraints(
|
|
|
|
|
minWidth: 32,
|
|
|
|
|
minHeight: 32,
|
|
|
|
|
minWidth: 28,
|
|
|
|
|
minHeight: 28,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|