You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

651 lines
26 KiB

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 '../../data/local/models/item.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<RelayManagementScreen> createState() => _RelayManagementScreenState();
}
class _RelayManagementScreenState extends State<RelayManagementScreen> {
final TextEditingController _urlController = TextEditingController();
bool _useNip05RelaysAutomatically = false;
bool _isDarkMode = false;
bool _isLoadingSetting = true;
// Media server settings
String? _mediaServerType; // 'immich' or 'blossom'
String? _immichBaseUrl;
String? _immichApiKey;
String? _blossomBaseUrl;
final TextEditingController _immichUrlController = TextEditingController();
final TextEditingController _immichKeyController = TextEditingController();
final TextEditingController _blossomUrlController = TextEditingController();
@override
void initState() {
super.initState();
_loadSetting();
}
@override
void dispose() {
_urlController.dispose();
_immichUrlController.dispose();
_immichKeyController.dispose();
_blossomUrlController.dispose();
super.dispose();
}
Future<void> _loadSetting() async {
try {
final localStorage = ServiceLocator.instance.localStorageService;
if (localStorage == null) {
setState(() {
_isLoadingSetting = false;
});
return;
}
// Read Immich enabled status from env (default: true)
final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
// Read default Blossom server from env
final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com';
final settingsItem = await localStorage.getItem('app_settings');
if (settingsItem != null) {
final useNip05 = settingsItem.data['use_nip05_relays_automatically'] == true;
final isDark = settingsItem.data['dark_mode'] == true;
final mediaServer = settingsItem.data['media_server_type'] as String?;
final immichUrl = settingsItem.data['immich_base_url'] as String?;
final immichKey = settingsItem.data['immich_api_key'] as String?;
final blossomUrl = settingsItem.data['blossom_base_url'] as String?;
// Default to Blossom if Immich is disabled, otherwise default to Immich
final defaultMediaServerType = immichEnabled ? 'immich' : 'blossom';
setState(() {
_useNip05RelaysAutomatically = useNip05;
_isDarkMode = isDark;
_mediaServerType = mediaServer ?? defaultMediaServerType;
_immichBaseUrl = immichUrl;
_immichApiKey = immichKey;
_blossomBaseUrl = blossomUrl ?? defaultBlossomServer;
_immichUrlController.text = immichUrl ?? '';
_immichKeyController.text = immichKey ?? '';
_blossomUrlController.text = blossomUrl ?? defaultBlossomServer;
_isLoadingSetting = false;
});
// Update theme notifier if available
final themeNotifier = ServiceLocator.instance.themeNotifier;
if (themeNotifier != null) {
themeNotifier.setThemeMode(isDark ? ThemeMode.dark : ThemeMode.light);
}
} else {
setState(() {
_mediaServerType = immichEnabled ? 'immich' : 'blossom';
_blossomBaseUrl = defaultBlossomServer;
_blossomUrlController.text = defaultBlossomServer;
_isLoadingSetting = false;
});
}
} catch (e) {
setState(() {
_isLoadingSetting = false;
});
}
}
Future<void> _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 ?? <String, dynamic>{};
data[key] = value;
await localStorage.insertItem(Item(
id: 'app_settings',
data: data,
));
setState(() {
if (key == 'use_nip05_relays_automatically') {
_useNip05RelaysAutomatically = value;
} else if (key == 'dark_mode') {
_isDarkMode = value;
// Notify the app to update theme
_updateAppTheme(value);
}
});
} catch (e) {
// Log error but don't show to user - setting will just not persist
}
}
Future<void> _saveMediaServerSettings() async {
try {
final localStorage = ServiceLocator.instance.localStorageService;
if (localStorage == null) return;
final settingsItem = await localStorage.getItem('app_settings');
final data = settingsItem?.data ?? <String, dynamic>{};
data['media_server_type'] = _mediaServerType;
data['immich_base_url'] = _immichBaseUrl;
data['immich_api_key'] = _immichApiKey;
data['blossom_base_url'] = _blossomBaseUrl;
await localStorage.insertItem(Item(
id: 'app_settings',
data: data,
));
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Media server settings saved'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
// Log error
}
}
void _updateAppTheme(bool isDark) {
// Update the theme notifier, which MyApp is listening to
final themeNotifier = ServiceLocator.instance.themeNotifier;
if (themeNotifier != null) {
themeNotifier.setThemeMode(isDark ? ThemeMode.dark : ThemeMode.light);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Nostr Relay Management'),
),
body: ListenableBuilder(
listenable: widget.controller,
builder: (context, child) {
return Column(
children: [
// Settings section
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Settings',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
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
Builder(
builder: (context) {
// Read Immich enabled status from env
final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
return ExpansionTile(
title: const Text('Media Server'),
subtitle: Text(_mediaServerType == 'immich' ? 'Immich' : 'Blossom'),
initiallyExpanded: false,
children: [
// Media server selection - only show Immich if enabled
if (immichEnabled)
RadioListTile<String>(
title: const Text('Immich'),
subtitle: const Text('Requires API key and URL'),
value: 'immich',
groupValue: _mediaServerType,
onChanged: (value) {
setState(() {
_mediaServerType = value;
});
_saveMediaServerSettings();
},
),
RadioListTile<String>(
title: const Text('Blossom'),
subtitle: const Text('Requires URL only (uses Nostr auth)'),
value: 'blossom',
groupValue: _mediaServerType,
onChanged: (value) {
setState(() {
_mediaServerType = value;
});
_saveMediaServerSettings();
},
),
const Divider(),
// Immich settings (only show if Immich selected and enabled)
if (_mediaServerType == 'immich' && immichEnabled) ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _immichUrlController,
decoration: const InputDecoration(
labelText: 'Immich Base URL',
hintText: 'https://immich.example.com',
border: OutlineInputBorder(),
),
onChanged: (value) {
_immichBaseUrl = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
),
const SizedBox(height: 16),
TextField(
controller: _immichKeyController,
decoration: const InputDecoration(
labelText: 'Immich API Key',
border: OutlineInputBorder(),
),
obscureText: true,
onChanged: (value) {
_immichApiKey = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
),
],
),
),
],
// Blossom settings (only show if Blossom selected)
if (_mediaServerType == 'blossom') ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _blossomUrlController,
decoration: const InputDecoration(
labelText: 'Blossom Base URL',
hintText: 'https://blossom.example.com',
border: OutlineInputBorder(),
),
onChanged: (value) {
_blossomBaseUrl = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
),
),
],
],
);
},
),
],
),
),
// 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,
),
],
),
),
// Top action buttons
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 {
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',
),
),
),
],
),
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 Divider(),
// Relay list
Expanded(
child: widget.controller.relays.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
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,
),
),
const SizedBox(height: 8),
Text(
'Add a relay to get started',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
],
),
)
: 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),
),
);
},
);
},
),
),
],
);
},
),
);
}
}
/// 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,
),
),
],
),
),
);
}
}

Powered by TurnKey Linux.