feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
|
||||
import '../bloc/admin_evaluation_history_bloc.dart';
|
||||
import '../models/evaluation_history_entry_model.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
import '../models/evaluation_history_summary_model.dart';
|
||||
import '../repositories/admin_repository.dart';
|
||||
import '../widgets/evaluation_history_filter_bar.dart';
|
||||
import '../widgets/evaluation_history_kpi_header.dart';
|
||||
import '../widgets/evaluation_history_list_item.dart';
|
||||
|
||||
/// Admin-only tab showing the full history of every asset evaluation the
|
||||
/// engine ever ran — approved or not, automatic or manual — so an admin can
|
||||
/// see directly *why* no new trade proposal appeared instead of having to
|
||||
/// query the database by hand. Backed by `GET /api/v1/admin/evaluations`
|
||||
/// (`AdminEvaluationHistoryController`, `[Authorize(Roles = "Admin")]`).
|
||||
///
|
||||
/// This screen is only ever mounted from `ResponsiveScaffold` behind an
|
||||
/// `if (widget.user.isAdmin)` guard, same as the Bot Panel/Backtest/Admin
|
||||
/// Panel tabs — that guard is UX only, not a security boundary. The real
|
||||
/// boundary is the server-side `[Authorize(Roles = "Admin")]`: if a non-admin
|
||||
/// (or an expired-token admin) somehow still reaches this screen, the 401/403
|
||||
/// response is caught by `ApiClient`'s central interceptor, which clears the
|
||||
/// stored token and triggers auto-logout (Rules.md §8) — the bloc below just
|
||||
/// has to not crash on the `AdminEvaluationHistoryError` that results in the
|
||||
/// meantime, which it doesn't (it renders a normal retryable error state).
|
||||
class AdminEvaluationHistoryScreen extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const AdminEvaluationHistoryScreen({super.key, required this.apiClient});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => AdminEvaluationHistoryBloc(
|
||||
repository: AdminRepository(apiClient: apiClient),
|
||||
)..add(const FetchEvaluationHistory()),
|
||||
child: _AdminEvaluationHistoryScreenContent(apiClient: apiClient),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminEvaluationHistoryScreenContent extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const _AdminEvaluationHistoryScreenContent({required this.apiClient});
|
||||
|
||||
@override
|
||||
State<_AdminEvaluationHistoryScreenContent> createState() => _AdminEvaluationHistoryScreenContentState();
|
||||
}
|
||||
|
||||
class _AdminEvaluationHistoryScreenContentState extends State<_AdminEvaluationHistoryScreenContent> {
|
||||
static const int _pageSize = 50;
|
||||
|
||||
DateTime? _fromUtc;
|
||||
DateTime? _toUtc;
|
||||
OutcomeReason? _outcome;
|
||||
TriggerSource? _triggerSource;
|
||||
String _search = '';
|
||||
int _page = 1;
|
||||
|
||||
void _fetch() {
|
||||
context.read<AdminEvaluationHistoryBloc>().add(FetchEvaluationHistory(
|
||||
fromUtc: _fromUtc,
|
||||
toUtc: _toUtc,
|
||||
outcome: _outcome,
|
||||
triggerSource: _triggerSource,
|
||||
search: _search,
|
||||
page: _page,
|
||||
pageSize: _pageSize,
|
||||
));
|
||||
}
|
||||
|
||||
void _onFilterChanged({
|
||||
required DateTime? fromUtc,
|
||||
required DateTime? toUtc,
|
||||
required OutcomeReason? outcome,
|
||||
required TriggerSource? triggerSource,
|
||||
required String search,
|
||||
}) {
|
||||
setState(() {
|
||||
_fromUtc = fromUtc;
|
||||
_toUtc = toUtc;
|
||||
_outcome = outcome;
|
||||
_triggerSource = triggerSource;
|
||||
_search = search;
|
||||
_page = 1;
|
||||
});
|
||||
_fetch();
|
||||
}
|
||||
|
||||
void _goToPage(int page) {
|
||||
setState(() => _page = page);
|
||||
_fetch();
|
||||
}
|
||||
|
||||
void _showDetail(EvaluationHistoryEntryModel entry) {
|
||||
final approvedLike = entry.outcomeReason == OutcomeReason.approved || entry.passedAiValidation;
|
||||
|
||||
EvaluationScoreBreakdownSheet.show(
|
||||
context,
|
||||
title: '${entry.symbol.isNotEmpty ? entry.symbol : entry.isin} · ${entry.outcomeReason.label}',
|
||||
subtitle: 'Evaluiert am ${_formatFullTimestamp(entry.evaluatedAtUtc)} · Ausgelöst: ${entry.triggerSource.label}.',
|
||||
headerIcon: approvedLike ? Icons.psychology_outlined : Icons.block_outlined,
|
||||
headerColor: approvedLike ? AppTheme.primaryEmerald : entry.outcomeReason.color,
|
||||
compositeScore: entry.compositeOpportunityScore,
|
||||
technicalScore: entry.technicalScore,
|
||||
sentimentScore: entry.sentimentScore,
|
||||
fundamentalScore: entry.fundamentalScore,
|
||||
reliabilityBonus: entry.reliabilityBonus,
|
||||
passedEarningsLockout: entry.passedEarningsLockout,
|
||||
daysToNextEarnings: entry.daysToNextEarnings,
|
||||
passedDividendGate: entry.passedDividendGate,
|
||||
daysToNextExDividend: entry.daysToNextExDividend,
|
||||
universeSourceLabel: entry.universeSource?.label,
|
||||
universeEnteredAtUtc: entry.universeEnteredAtUtc,
|
||||
passedSimulationVeto: entry.passedSimulationVeto,
|
||||
reasoningLabel: entry.passedAiValidation ? 'KI-These' : 'Ablehnungsgrund',
|
||||
reasoningText: entry.aiThesisSummary,
|
||||
footer: entry.hasProposal
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.rocket_launch_outlined, size: 16, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Aus dieser Analyse entstand ein Trade-Vorschlag (Proposal-ID: ${entry.proposalId}).',
|
||||
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 12, height: 1.4, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFullTimestamp(DateTime utc) {
|
||||
final local = utc.toLocal();
|
||||
final d = local.day.toString().padLeft(2, '0');
|
||||
final m = local.month.toString().padLeft(2, '0');
|
||||
final h = local.hour.toString().padLeft(2, '0');
|
||||
final min = local.minute.toString().padLeft(2, '0');
|
||||
return '$d.$m.${local.year} $h:$min';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Evaluierungs-Historie',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Jede Analyse, jeder Filter, jedes Ergebnis – nachvollziehbar ohne DB-Zugriff.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _fetch,
|
||||
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||
tooltip: 'Neu laden',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: BlocBuilder<AdminEvaluationHistoryBloc, AdminEvaluationHistoryState>(
|
||||
builder: (context, state) {
|
||||
final summary = state is AdminEvaluationHistoryLoaded ? state.response.summary : EvaluationHistorySummaryModel.empty();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
EvaluationHistoryKpiHeader(summary: summary, apiClient: widget.apiClient),
|
||||
const SizedBox(height: 16),
|
||||
EvaluationHistoryFilterBar(
|
||||
fromUtc: _fromUtc,
|
||||
toUtc: _toUtc,
|
||||
outcome: _outcome,
|
||||
triggerSource: _triggerSource,
|
||||
search: _search,
|
||||
onChanged: _onFilterChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildBody(state),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(AdminEvaluationHistoryState state) {
|
||||
if (state is AdminEvaluationHistoryLoading || state is AdminEvaluationHistoryInitial) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is AdminEvaluationHistoryError) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded, color: AppTheme.accentRed, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _fetch,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final loaded = state as AdminEvaluationHistoryLoaded;
|
||||
final entries = loaded.response.entries;
|
||||
|
||||
if (entries.isEmpty) {
|
||||
// Explicit empty state (Rules.md §4) — never a silent blank list, so an
|
||||
// admin who set a narrow filter knows the filter matched nothing rather
|
||||
// than wondering whether the tab itself is broken.
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 44, color: AppTheme.textMuted),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Keine Analysen im gewählten Zeitraum/Filter gefunden.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Server already returns each page sorted by EvaluatedAtUtc descending
|
||||
// (EvaluationHistoryService.GetHistoryAsync: .OrderByDescending(s =>
|
||||
// s.EvaluatedAtUtc)) — rendered in received order, no client re-sort needed.
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return EvaluationHistoryListItem(entry: entry, onTap: () => _showDetail(entry));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
loaded.response.totalCount == 0
|
||||
? '0 Einträge'
|
||||
: '${loaded.rangeStart}–${loaded.rangeEnd} von ${loaded.response.totalCount}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: loaded.hasPreviousPage ? () => _goToPage(_page - 1) : null,
|
||||
child: const Text('Zurück'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: loaded.hasNextPage ? () => _goToPage(_page + 1) : null,
|
||||
child: const Text('Weiter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,11 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
}
|
||||
|
||||
String _formatLabel(String key) {
|
||||
return 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)')
|
||||
@@ -143,6 +147,187 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
.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(
|
||||
@@ -179,66 +364,7 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
if (_settings.isEmpty)
|
||||
const Text('Keine spezifischen Einstellungen gefunden.')
|
||||
else
|
||||
..._settings.map((s) {
|
||||
final key = s.key;
|
||||
final desc = s.description;
|
||||
final type = s.type.toLowerCase();
|
||||
final controller = _controllers[key];
|
||||
if (controller == null) return const SizedBox.shrink();
|
||||
|
||||
final isBoolean = type == 'bool' ||
|
||||
controller.text.toLowerCase() == 'true' ||
|
||||
controller.text.toLowerCase() == 'false';
|
||||
|
||||
if (isBoolean) {
|
||||
final boolVal = controller.text.toLowerCase() == 'true';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
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(key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
subtitle: desc.isNotEmpty ? Text(desc, 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final isNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
||||
|
||||
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(key),
|
||||
helperText: desc.isNotEmpty ? desc : null,
|
||||
helperMaxLines: 2,
|
||||
prefixIcon: Icon(
|
||||
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
|
||||
size: 18,
|
||||
color: AppTheme.primaryEmerald,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
..._buildGroupedSettingsSections(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
if (_settings.isNotEmpty)
|
||||
@@ -269,18 +395,6 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
serviceName: widget.serviceName,
|
||||
apiClient: widget.apiClient,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Statistiken', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Live-Statistiken werden noch implementiert...', style: TextStyle(fontStyle: FontStyle.italic, color: Colors.white54)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user