feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core

This commit is contained in:
2026-08-24 21:37:43 +02:00
parent 676496b77d
commit 0894c40f07
113 changed files with 12413 additions and 3613 deletions
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/evaluation_history_enums.dart';
/// Full filter snapshot emitted by [EvaluationHistoryFilterBar.onChanged] on
/// every single control change — deliberately not a partial/sparse update, so
/// there is no ambiguity between "caller didn't touch this field" and "caller
/// explicitly cleared this field" on the receiving end.
typedef EvaluationHistoryFilterChanged = void Function({
required DateTime? fromUtc,
required DateTime? toUtc,
required OutcomeReason? outcome,
required TriggerSource? triggerSource,
required String search,
});
/// Filter bar for the admin evaluation-history tab: a from/to date range (plain
/// `showDatePicker` — a full calendar-range widget is overkill for "roughly which
/// days"), an [OutcomeReason] dropdown, a [TriggerSource] dropdown, and an
/// ISIN/symbol search field. All four map 1:1 onto the server's optional query
/// filters (`fromUtc`/`toUtc`/`outcome`/`triggerSource`/`search`).
class EvaluationHistoryFilterBar extends StatefulWidget {
final DateTime? fromUtc;
final DateTime? toUtc;
final OutcomeReason? outcome;
final TriggerSource? triggerSource;
final String search;
final EvaluationHistoryFilterChanged onChanged;
const EvaluationHistoryFilterBar({
super.key,
required this.fromUtc,
required this.toUtc,
required this.outcome,
required this.triggerSource,
required this.search,
required this.onChanged,
});
@override
State<EvaluationHistoryFilterBar> createState() => _EvaluationHistoryFilterBarState();
}
class _EvaluationHistoryFilterBarState extends State<EvaluationHistoryFilterBar> {
late final TextEditingController _searchController;
@override
void initState() {
super.initState();
_searchController = TextEditingController(text: widget.search);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _pickDate({required bool isFrom}) async {
final initial = (isFrom ? widget.fromUtc : widget.toUtc)?.toLocal() ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2020, 1, 1),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked == null) return;
if (isFrom) {
_emit(fromUtc: DateTime.utc(picked.year, picked.month, picked.day));
} else {
// Inclusive upper bound on the whole selected day.
_emit(toUtc: DateTime.utc(picked.year, picked.month, picked.day, 23, 59, 59));
}
}
/// Emits the full filter snapshot, overriding only the field(s) that
/// actually changed and carrying every other field through unchanged from
/// `widget.*` — see [EvaluationHistoryFilterChanged].
void _emit({
Object? fromUtc = _unset,
Object? toUtc = _unset,
Object? outcome = _unset,
Object? triggerSource = _unset,
String? search,
}) {
widget.onChanged(
fromUtc: fromUtc == _unset ? widget.fromUtc : fromUtc as DateTime?,
toUtc: toUtc == _unset ? widget.toUtc : toUtc as DateTime?,
outcome: outcome == _unset ? widget.outcome : outcome as OutcomeReason?,
triggerSource: triggerSource == _unset ? widget.triggerSource : triggerSource as TriggerSource?,
search: search ?? widget.search,
);
}
String _formatDate(DateTime? dt) {
if (dt == null) return 'Egal';
final local = dt.toLocal();
return '${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year}';
}
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onSubmitted: (val) => _emit(search: val),
decoration: InputDecoration(
hintText: 'ISIN oder Symbol suchen...',
prefixIcon: Icon(Icons.search_rounded, color: AppTheme.textMuted),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
_searchController.clear();
_emit(search: '');
},
)
: IconButton(
icon: const Icon(Icons.arrow_forward, size: 18),
onPressed: () => _emit(search: _searchController.text),
),
),
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_dateChip(label: 'Von: ${_formatDate(widget.fromUtc)}', onTap: () => _pickDate(isFrom: true)),
_dateChip(label: 'Bis: ${_formatDate(widget.toUtc)}', onTap: () => _pickDate(isFrom: false)),
if (widget.fromUtc != null || widget.toUtc != null)
TextButton(
onPressed: () => _emit(fromUtc: null, toUtc: null),
child: const Text('Zeitraum zurücksetzen', style: TextStyle(fontSize: 12)),
),
SizedBox(
width: 190,
child: DropdownButtonFormField<OutcomeReason?>(
initialValue: widget.outcome,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Ergebnis', isDense: true),
items: [
const DropdownMenuItem<OutcomeReason?>(value: null, child: Text('Alle Ergebnisse')),
...OutcomeReason.values.map(
(r) => DropdownMenuItem<OutcomeReason?>(value: r, child: Text(r.label)),
),
],
onChanged: (val) => _emit(outcome: val),
),
),
SizedBox(
width: 170,
child: DropdownButtonFormField<TriggerSource?>(
initialValue: widget.triggerSource,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Ausgelöst durch', isDense: true),
items: [
const DropdownMenuItem<TriggerSource?>(value: null, child: Text('Alle Quellen')),
...TriggerSource.values.map(
(t) => DropdownMenuItem<TriggerSource?>(value: t, child: Text(t.label)),
),
],
onChanged: (val) => _emit(triggerSource: val),
),
),
],
),
],
),
);
}
Widget _dateChip({required String label, required VoidCallback onTap}) {
return OutlinedButton.icon(
onPressed: onTap,
icon: Icon(Icons.calendar_today_outlined, size: 14, color: AppTheme.textSecondary),
label: Text(label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppTheme.glassBorder),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
);
}
}
/// Sentinel distinguishing "caller of [_EvaluationHistoryFilterBarState._emit]
/// didn't touch this field" (default) from "caller explicitly passed `null`"
/// (clear this field) — needed because `Object?`'s own null is one of the two
/// values this default has to be distinguishable from.
const Object _unset = Object();
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/time_utils.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/evaluation_history_enums.dart';
import '../models/evaluation_history_summary_model.dart';
import 'watchlist_card.dart';
/// Headline KPI row for the admin evaluation-history tab: how many analyses ran
/// in the current filter window, the outcome breakdown (this is what makes a
/// "why are there no new proposals" question answerable at a glance — e.g. most
/// assets sitting in [OutcomeReason.belowScoreThreshold]), the average composite
/// score, and how long ago the last trade proposal was actually created.
///
/// Every number here comes straight from `EvaluationHistorySummaryModel`
/// (server-aggregated over the same filtered set as the entry list) — nothing is
/// computed client-side from the current page alone (Rules.md §4).
class EvaluationHistoryKpiHeader extends StatelessWidget {
final EvaluationHistorySummaryModel summary;
final ApiClient apiClient;
const EvaluationHistoryKpiHeader({super.key, required this.summary, required this.apiClient});
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 700;
final lastProposalText = summary.lastProposalCreatedAtUtc == null
? 'Noch nie'
: TimeUtils.formatRelativeTime(summary.lastProposalCreatedAtUtc!.toIso8601String());
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
isMobile
? Column(
children: [
_kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan),
const SizedBox(height: 10),
_kpiCard('Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
const SizedBox(height: 10),
_kpiCard(
'Letzter Vorschlag',
lastProposalText,
Icons.rocket_launch_outlined,
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
),
],
)
: Row(
children: [
Expanded(child: _kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan)),
const SizedBox(width: 10),
Expanded(
child: _kpiCard(
'Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
),
const SizedBox(width: 10),
Expanded(
child: _kpiCard(
'Letzter Vorschlag',
lastProposalText,
Icons.rocket_launch_outlined,
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
),
),
],
),
const SizedBox(height: 10),
isMobile
? Column(
children: [
_buildBreakdownCard(),
const SizedBox(height: 10),
WatchlistCard(apiClient: apiClient),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 2, child: _buildBreakdownCard()),
const SizedBox(width: 10),
Expanded(child: WatchlistCard(apiClient: apiClient)),
],
),
],
);
}
Widget _buildBreakdownCard() {
return GlassContainer(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AUFSCHLÜSSELUNG NACH GRUND', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 10),
summary.totalEvaluations == 0
? Text('Keine Analysen im gewählten Filter.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12))
: Wrap(
spacing: 8,
runSpacing: 8,
children: OutcomeReason.values
.map((reason) => _outcomeChip(reason, summary.countFor(reason)))
.where((w) => w != null)
.cast<Widget>()
.toList(),
),
],
),
);
}
Widget? _outcomeChip(OutcomeReason reason, int count) {
if (count == 0) return null;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: reason.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: reason.color.withValues(alpha: 0.4)),
),
child: Text(
'${reason.label}: $count',
style: TextStyle(color: reason.color, fontWeight: FontWeight.bold, fontSize: 12),
),
);
}
Widget _kpiCard(String title, String value, IconData icon, Color color) {
return GlassContainer(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: TextStyle(fontSize: 11, color: AppTheme.textMuted, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(fontSize: 15, color: AppTheme.textPrimary, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../models/evaluation_history_entry_model.dart';
/// One row of the paginated evaluation-history list: symbol/ISIN, timestamp,
/// composite score, and color-coded [OutcomeReason]/[TriggerSource] badges.
/// Tapping opens the full score/reasoning breakdown via the caller-supplied
/// [onTap] (wired to the shared `EvaluationScoreBreakdownSheet` by the screen).
class EvaluationHistoryListItem extends StatelessWidget {
final EvaluationHistoryEntryModel entry;
final VoidCallback onTap;
const EvaluationHistoryListItem({super.key, required this.entry, required this.onTap});
String _formatTimestamp(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) {
final scoreColor = entry.compositeOpportunityScore >= 70
? AppTheme.primaryEmerald
: (entry.compositeOpportunityScore >= 40 ? Colors.amber : AppTheme.accentRed);
return GlassContainer(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
onTap: onTap,
child: Row(
children: [
SizedBox(
width: 48,
height: 48,
child: Center(
child: Text(
entry.compositeOpportunityScore.toStringAsFixed(0),
style: TextStyle(color: scoreColor, fontWeight: FontWeight.bold, fontSize: 18),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
entry.symbol.isNotEmpty ? entry.symbol : entry.isin,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
),
if (entry.symbol.isNotEmpty && entry.isin.isNotEmpty) ...[
const SizedBox(width: 6),
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
if (entry.hasProposal) ...[
const SizedBox(width: 6),
Icon(Icons.link_rounded, size: 13, color: AppTheme.primaryEmerald),
],
],
),
const SizedBox(height: 4),
Text(_formatTimestamp(entry.evaluatedAtUtc), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
StatusBadge(label: entry.outcomeReason.label, color: entry.outcomeReason.color),
const SizedBox(height: 6),
StatusBadge(label: entry.triggerSource.label, color: entry.triggerSource.color),
],
),
const SizedBox(width: 4),
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 20),
],
),
);
}
}
@@ -157,9 +157,33 @@ class _LiveLogConsoleState extends State<LiveLogConsole> {
}
}
/// Collapses the backend's full `Microsoft.Extensions.Logging.LogLevel` names ("Information", "Warning",
/// "Trace", "Critical", ...) down to the 4 short codes the filter chips use ("INFO", "WARN", "DEBUG",
/// "ERROR"). The filter previously compared `log.level.toUpperCase()` ("INFORMATION") directly against the
/// chip value ("INFO") - which never matched anything but "ALL", so selecting any specific level silently
/// hid every log line instead of actually filtering.
String _normalizeLevel(String level) {
switch (level.toUpperCase()) {
case 'INFORMATION':
case 'INFO':
return 'INFO';
case 'WARNING':
case 'WARN':
return 'WARN';
case 'ERROR':
case 'CRITICAL':
return 'ERROR';
case 'DEBUG':
case 'TRACE':
return 'DEBUG';
default:
return level.toUpperCase();
}
}
List<LogMessageDto> get _filteredLogs {
return _logs.where((log) {
if (_selectedLevel != 'ALL' && log.level.toUpperCase() != _selectedLevel) {
if (_selectedLevel != 'ALL' && _normalizeLevel(log.level) != _selectedLevel) {
return false;
}
if (_searchQuery.isNotEmpty) {
@@ -67,16 +67,9 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
accentColor: Color(0xFFEC4899),
),
ServiceConfigMeta(
key: 'FinlyticAnalyzer',
displayName: 'Analyzer Signal Engine',
description: 'Scraper Cron-Schedule & Minimaler Signal-Score',
icon: Icons.analytics_outlined,
accentColor: Color(0xFFF59E0B),
),
ServiceConfigMeta(
key: 'FinlyticTrades',
displayName: 'Trade Manager',
description: 'ATR Stop-Loss Multiplikator, Risiko-Prozente & Positionen',
key: 'FinlyticEngine',
displayName: 'Trading Engine',
description: 'Strategy Screener, Trade Lifecycle & Risikomanagement',
icon: Icons.candlestick_chart_outlined,
accentColor: Color(0xFF10B981),
),
@@ -87,6 +80,13 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
icon: Icons.corporate_fare_outlined,
accentColor: Color(0xFF06B6D4),
),
ServiceConfigMeta(
key: 'FinlyticBot',
displayName: 'FinlyticBot (Paper)',
description: 'Alpaca Paper Trading, Risikomanagement & Sizing Engine',
icon: Icons.smart_toy_outlined,
accentColor: Color(0xFF10B981),
),
];
final Map<String, Map<String, TextEditingController>> _controllers = {
@@ -113,24 +113,35 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
'MinConfidenceScore': TextEditingController(text: '0.70'),
'MaxBatchSize': TextEditingController(text: '50'),
},
'FinlyticAnalyzer': {
'ScanCronSchedule': TextEditingController(text: '0 */1 * * *'),
'MinSignalScore': TextEditingController(text: '75'),
'EnableLog_MqttHealthPing': TextEditingController(text: 'false'),
'EnableLog_MqttGeneral': TextEditingController(text: 'true'),
'EnableLog_AnalyzerAuto': TextEditingController(text: 'true'),
'EnableLog_AnalyzerManual': TextEditingController(text: 'true'),
'EnableLog_DatabaseOps': TextEditingController(text: 'true'),
},
'FinlyticTrades': {
'AtrStopLossMultiplier': TextEditingController(text: '1.5'),
'RiskPerTradePercentage': TextEditingController(text: '1.0'),
'MaxOpenPositions': TextEditingController(text: '5'),
'FinlyticEngine': {
'Engine.MinCompositeScore': TextEditingController(text: '75.0'),
'Engine.WeightTechnical': TextEditingController(text: '0.45'),
'Engine.WeightSentiment': TextEditingController(text: '0.35'),
'Engine.WeightFundamental': TextEditingController(text: '0.20'),
'Engine.EarningsLockoutDays': TextEditingController(text: '2'),
'Engine.MinDerivativeLeverage': TextEditingController(text: '5.0'),
'Engine.TargetDefaultLeverage': TextEditingController(text: '7.0'),
'Engine.KnockOutSafetyBufferPercent': TextEditingController(text: '2.0'),
'Engine.EnableAiValidation': TextEditingController(text: 'true'),
'Engine.EnablePaperTradingBot': TextEditingController(text: 'false'),
'Engine.PollingIntervalSeconds': TextEditingController(text: '120'),
'Engine.MonitoringIntervalSeconds': TextEditingController(text: '60'),
},
'FinlyticFundamentals': {
'CacheTtlHours': TextEditingController(text: '24'),
'EnableYahooFallback': TextEditingController(text: 'true'),
},
'FinlyticBot': {
'Alpaca.KeyId': TextEditingController(text: ''),
'Alpaca.SecretKey': TextEditingController(text: ''),
'Alpaca.IsPaper': TextEditingController(text: 'true'),
'Bot.EnableAutoExecution': TextEditingController(text: 'true'),
'Bot.RiskPerTradePercent': TextEditingController(text: '1.0'),
'Bot.MaxPositionAllocationPercent': TextEditingController(text: '20.0'),
'Bot.MaxConcurrentPositions': TextEditingController(text: '5'),
'Bot.DailyLossLimitPercent': TextEditingController(text: '3.0'),
'Bot.MonitoringIntervalSeconds': TextEditingController(text: '15'),
},
};
bool _initialized = false;
@@ -46,6 +46,32 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
super.dispose();
}
IconData _getServiceIcon(String name) {
switch (name) {
case 'FinlyticBackend':
return Icons.hub_outlined;
case 'FinlyticAssets':
return Icons.inventory_2_outlined;
case 'FinlyticNews':
return Icons.newspaper_outlined;
case 'FinlyticTechnicals':
case 'FinlyticTechnicalAnalysis':
return Icons.show_chart_outlined;
case 'FinlyticSentiment':
return Icons.psychology_outlined;
case 'FinlyticEngine':
case 'FinlyticAnalyzer':
case 'FinlyticTrades':
return Icons.candlestick_chart_outlined;
case 'FinlyticFundamentals':
return Icons.corporate_fare_outlined;
case 'FinlyticBot':
return Icons.smart_toy_outlined;
default:
return Icons.dns_outlined;
}
}
@override
Widget build(BuildContext context) {
final int totalCount = _serviceStatuses.length;
@@ -191,7 +217,7 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
Row(
children: [
Icon(
name == 'FinlyticBackend' ? Icons.hub_outlined : Icons.dns_outlined,
_getServiceIcon(name),
size: 18,
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
@@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/recent_setup_model.dart';
import '../models/watchlist_entry_model.dart';
import '../repositories/admin_repository.dart';
/// Card sitting next to "AUFSCHLÜSSELUNG NACH GRUND" showing how many assets
/// FinlyticTechnicals' background scanner is currently watching. Tapping opens
/// a dialog listing every entry — this directly answers "is anything even
/// being checked in the background right now", independent of whether any of
/// those checks have (yet) produced a proposal-worthy evaluation the engine
/// history tab above would show.
class WatchlistCard extends StatefulWidget {
final ApiClient apiClient;
const WatchlistCard({super.key, required this.apiClient});
@override
State<WatchlistCard> createState() => _WatchlistCardState();
}
class _WatchlistCardState extends State<WatchlistCard> {
late final AdminRepository _repository = AdminRepository(apiClient: widget.apiClient);
List<WatchlistEntryModel>? _entries;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final entries = await _repository.fetchWatchlist();
if (!mounted) return;
setState(() {
_entries = entries;
_error = null;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
void _showDialog() {
showDialog(
context: context,
builder: (_) => _WatchlistDialog(repository: _repository, initialEntries: _entries ?? const []),
);
}
@override
Widget build(BuildContext context) {
final count = _entries?.length;
final value = _error != null ? '' : (count?.toString() ?? '');
return GlassContainer(
padding: const EdgeInsets.all(14),
onTap: _showDialog,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('WATCHLIST', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const Spacer(),
Icon(Icons.list_alt_rounded, size: 16, color: AppTheme.accentCyan),
],
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value, style: TextStyle(fontSize: 22, color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('überwachte Assets', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
),
],
),
if (_error != null) ...[
const SizedBox(height: 6),
Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 11)),
],
],
),
);
}
}
class _WatchlistDialog extends StatefulWidget {
final AdminRepository repository;
final List<WatchlistEntryModel> initialEntries;
const _WatchlistDialog({required this.repository, required this.initialEntries});
@override
State<_WatchlistDialog> createState() => _WatchlistDialogState();
}
class _WatchlistDialogState extends State<_WatchlistDialog> {
late List<WatchlistEntryModel> _entries = widget.initialEntries;
bool _refreshing = false;
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
final fresh = await widget.repository.fetchWatchlist();
if (!mounted) return;
setState(() {
_entries = fresh;
_refreshing = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _refreshing = false);
}
}
String _formatTimestamp(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) {
final activeTheme = AppTheme.activePreset;
return Dialog(
backgroundColor: activeTheme.cardSurface,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
child: GlassContainer(
borderRadius: 20,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.list_alt_rounded, color: AppTheme.accentCyan, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text('Watchlist (${_entries.length})', style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
),
IconButton(
onPressed: _refreshing ? null : _refresh,
icon: _refreshing
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh_rounded, color: Colors.white70),
tooltip: 'Neu laden',
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded, color: Colors.white70),
),
],
),
Text(
'Assets, die FinlyticTechnicals derzeit im Hintergrund fortlaufend überprüft. Eintrag antippen für die letzten Bewertungen.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
const SizedBox(height: 12),
if (_entries.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text('Die Watchlist ist derzeit leer.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: _entries.length,
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white12),
itemBuilder: (context, index) => _WatchlistEntryTile(
entry: _entries[index],
repository: widget.repository,
formatTimestamp: _formatTimestamp,
),
),
),
],
),
),
),
);
}
}
class _WatchlistEntryTile extends StatefulWidget {
final WatchlistEntryModel entry;
final AdminRepository repository;
final String Function(DateTime) formatTimestamp;
const _WatchlistEntryTile({required this.entry, required this.repository, required this.formatTimestamp});
@override
State<_WatchlistEntryTile> createState() => _WatchlistEntryTileState();
}
class _WatchlistEntryTileState extends State<_WatchlistEntryTile> {
List<RecentSetupModel>? _history;
bool _loading = false;
String? _error;
Future<void> _loadHistory() async {
if (_history != null || _loading) return;
setState(() => _loading = true);
try {
final history = await widget.repository.fetchWatchlistEntryHistory(widget.entry.isin);
if (!mounted) return;
setState(() {
_history = history;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final entry = widget.entry;
return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
onExpansionChanged: (expanded) {
if (expanded) _loadHistory();
},
tilePadding: EdgeInsets.zero,
title: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.symbol?.isNotEmpty == true ? entry.symbol! : entry.isin,
style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold, fontSize: 14),
),
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
if (entry.source != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.4)),
),
child: Text(entry.source!.label, style: TextStyle(color: AppTheme.accentCyan, fontSize: 10, fontWeight: FontWeight.bold)),
),
],
),
subtitle: Text(
'Seit ${widget.formatTimestamp(entry.addedAtUtc)}'
'${entry.expiresAtUtc != null ? ' · Läuft ab ${widget.formatTimestamp(entry.expiresAtUtc!)}' : ''}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildHistoryBody(),
),
],
),
);
}
Widget _buildHistoryBody() {
if (_loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
);
}
if (_error != null) {
return Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 12));
}
final history = _history ?? const [];
if (history.isEmpty) {
return Text(
'Noch keine technische Bewertung für dieses Asset erfasst.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('LETZTE BEWERTUNGEN', style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 6),
...history.map((setup) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(widget.formatTimestamp(setup.createdAt), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
),
Expanded(
child: Text(setup.strategyName, style: TextStyle(color: AppTheme.textPrimary, fontSize: 11), overflow: TextOverflow.ellipsis),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
setup.qualityScore.toStringAsFixed(1),
style: TextStyle(
color: setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
)),
],
);
}
}