Files

404 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../models/service_setting_dto.dart';
import '../repositories/admin_repository.dart';
import '../widgets/live_log_console.dart';
class ServiceDetailScreen extends StatefulWidget {
final String serviceName;
final ApiClient apiClient;
final AdminRepository? repository;
const ServiceDetailScreen({
super.key,
required this.serviceName,
required this.apiClient,
this.repository,
});
@override
State<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
}
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
late final AdminRepository _repository;
bool _isLoading = true;
bool _isSaving = false;
String _error = '';
List<ServiceSettingDto> _settings = [];
final Map<String, TextEditingController> _controllers = {};
@override
void initState() {
super.initState();
_repository = widget.repository ?? AdminRepository(apiClient: widget.apiClient);
_fetchServiceDetails();
}
@override
void dispose() {
for (var ctrl in _controllers.values) {
ctrl.dispose();
}
super.dispose();
}
Future<void> _fetchServiceDetails() async {
try {
final groupedSettings = await _repository.fetchSettings();
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
setState(() {
_settings = serviceSettings;
for (var s in _settings) {
final key = s.key;
final val = s.value;
if (!_controllers.containsKey(key)) {
_controllers[key] = TextEditingController(text: val);
} else {
_controllers[key]!.text = val;
}
}
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _saveSettings() async {
setState(() => _isSaving = true);
try {
final payload = <String, dynamic>{};
_controllers.forEach((k, v) {
final text = v.text.trim();
if (text.toLowerCase() == 'true') {
payload[k] = true;
} else if (text.toLowerCase() == 'false') {
payload[k] = false;
} else if (int.tryParse(text) != null) {
payload[k] = int.parse(text);
} else if (double.tryParse(text) != null) {
payload[k] = double.parse(text);
} else {
payload[k] = text;
}
});
await _repository.updateServiceSettings(widget.serviceName, payload);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.black),
const SizedBox(width: 8),
Expanded(
child: Text(
'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
),
),
],
),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $e'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
String _formatLabel(String key) {
// Strip the "Logging.Channel." prefix for display - the section header already says "Logging-Kanäle",
// repeating it on every single chip label added visual noise without any extra information.
final withoutChannelPrefix = key.startsWith('Logging.Channel.') ? key.substring('Logging.Channel.'.length) : key;
return withoutChannelPrefix
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
.replaceAll('Minutes', '(Minuten)')
.replaceAll('Seconds', '(Sekunden)')
.replaceAll('Hours', '(Stunden)')
.replaceAll('Days', '(Tage)')
.replaceAll('Limit', 'Grenzwert')
.replaceAll('Period', 'Periode')
.replaceAll('Percentage', '(%)')
.replaceAll('Multiplier', 'Multiplikator');
}
/// Groups settings by kind so the settings card reads as organized sections instead of one long,
/// unstructured list mixing logging toggles, feature switches, numeric thresholds, and free text together.
/// Order is fixed (not alphabetical) so the most-scanned category (logging channels, usually the most
/// numerous) sits first.
static const List<String> _categoryOrder = ['Logging-Kanäle', 'Umschalter', 'Zahlenwerte', 'Text'];
String _categoryFor(ServiceSettingDto s) {
if (s.key.startsWith('Logging.Channel.')) return 'Logging-Kanäle';
final type = s.type.toLowerCase();
final looksBoolean = type == 'bool' || s.value.toLowerCase() == 'true' || s.value.toLowerCase() == 'false';
if (looksBoolean) return 'Umschalter';
final looksNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
if (looksNumeric) return 'Zahlenwerte';
return 'Text';
}
Map<String, List<ServiceSettingDto>> get _groupedSettings {
final groups = <String, List<ServiceSettingDto>>{};
for (final s in _settings) {
groups.putIfAbsent(_categoryFor(s), () => []).add(s);
}
return groups;
}
Widget _buildSectionHeader(String title, IconData icon) {
return Padding(
padding: const EdgeInsets.only(bottom: 10, top: 4),
child: Row(
children: [
Icon(icon, size: 15, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textMuted, letterSpacing: 0.4),
),
],
),
);
}
/// Compact toggle "chip" for a single boolean setting - used for logging channels, which can easily number
/// a dozen+ per service, so a full-width `SwitchListTile` per entry (the previous, only, layout for every
/// setting regardless of category or count) made the card feel "gequetscht"/cramped and pushed the actually
/// important numeric settings far down the page.
Widget _buildToggleChip(ServiceSettingDto s, TextEditingController controller) {
final boolVal = controller.text.toLowerCase() == 'true';
return Tooltip(
message: s.description.isNotEmpty ? s.description : _formatLabel(s.key),
triggerMode: TooltipTriggerMode.tap,
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => setState(() => controller.text = (!boolVal).toString()),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.5) : AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
boolVal ? Icons.check_circle : Icons.circle_outlined,
size: 14,
color: boolVal ? AppTheme.primaryEmerald : AppTheme.textMuted,
),
const SizedBox(width: 6),
Text(
_formatLabel(s.key),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: boolVal ? Colors.white : AppTheme.textMuted,
),
),
],
),
),
),
);
}
Widget _buildSwitchSetting(ServiceSettingDto s, TextEditingController controller) {
final boolVal = controller.text.toLowerCase() == 'true';
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : AppTheme.glassBorder),
),
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(_formatLabel(s.key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: s.description.isNotEmpty ? Text(s.description, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
value: boolVal,
activeThumbColor: AppTheme.primaryEmerald,
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
onChanged: (val) => setState(() => controller.text = val.toString()),
),
);
}
Widget _buildTextFieldSetting(ServiceSettingDto s, TextEditingController controller, {required bool isNumeric}) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextField(
controller: controller,
keyboardType: isNumeric ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.text,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: _formatLabel(s.key),
helperText: s.description.isNotEmpty ? s.description : null,
helperMaxLines: 2,
prefixIcon: Icon(
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
size: 18,
color: AppTheme.primaryEmerald,
),
),
),
);
}
List<Widget> _buildGroupedSettingsSections() {
final grouped = _groupedSettings;
final widgets = <Widget>[];
for (final category in _categoryOrder) {
final items = grouped[category];
if (items == null || items.isEmpty) continue;
widgets.add(_buildSectionHeader(
'$category (${items.length})',
switch (category) {
'Logging-Kanäle' => Icons.terminal_rounded,
'Umschalter' => Icons.toggle_on_outlined,
'Zahlenwerte' => Icons.numbers_outlined,
_ => Icons.tune_outlined,
},
));
if (category == 'Logging-Kanäle') {
final chips = <Widget>[];
for (final s in items) {
final controller = _controllers[s.key];
if (controller != null) chips.add(_buildToggleChip(s, controller));
}
widgets.add(Wrap(spacing: 8, runSpacing: 8, children: chips));
} else if (category == 'Umschalter') {
for (final s in items) {
final controller = _controllers[s.key];
if (controller != null) widgets.add(_buildSwitchSetting(s, controller));
}
} else {
for (final s in items) {
final controller = _controllers[s.key];
if (controller != null) {
widgets.add(_buildTextFieldSetting(s, controller, isNumeric: category == 'Zahlenwerte'));
}
}
}
widgets.add(const SizedBox(height: 14));
}
return widgets;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.darkBackground,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text('${widget.serviceName} Details', style: const TextStyle(fontWeight: FontWeight.bold)),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error.isNotEmpty
? Center(child: Text(_error, style: const TextStyle(color: Colors.red)))
: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Einstellungen & Konfiguration', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
StatusBadge(label: 'MQTT Sync', color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
if (_settings.isEmpty)
const Text('Keine spezifischen Einstellungen gefunden.')
else
..._buildGroupedSettingsSections(),
const SizedBox(height: 12),
if (_settings.isNotEmpty)
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.save_outlined),
label: Text(
_isSaving ? 'Speichere & Sende via MQTT...' : 'Einstellungen Speichern',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
const SizedBox(height: 24),
LiveLogConsole(
serviceName: widget.serviceName,
apiClient: widget.apiClient,
),
],
),
),
);
}
}