feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user