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,388 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
/// Reusable bottom-sheet body showing the full score breakdown, earnings-lockout
/// (and, where available, simulation-veto) status and AI reasoning for a single
/// asset evaluation.
///
/// Used by both `TradesTab._showEvaluationRejectedSheet` (manual "Analyze now"
/// result, backed by `AssetEvaluationResultModel`) and the admin
/// evaluation-history detail sheet (backed by `EvaluationHistoryEntryModel`).
/// The two source models diverge in exactly the fields this widget doesn't
/// need (proposal payload vs. outcome/trigger enums), so what is shared here
/// is the *display logic* via plain primitives, not the models themselves —
/// that is what actually avoids a second, drifting copy of this sheet's UI.
class EvaluationScoreBreakdownSheet extends StatelessWidget {
final ScrollController scrollController;
final String title;
final String subtitle;
final IconData headerIcon;
final Color headerColor;
final double compositeScore;
final double technicalScore;
final double sentimentScore;
final double fundamentalScore;
/// Simulation-matrix bonus points (0..~15). `null` omits the tile entirely —
/// the manual-analysis result (`AssetEvaluationResultModel`) never carries this.
final double? reliabilityBonus;
final bool passedEarningsLockout;
final int? daysToNextEarnings;
/// Whether the ex-dividend gate passed (see `Engine.DividendGateDays`). Defaults to `true` so call sites
/// that predate this gate (or a "no evaluation reached" early-return case) read as "not gated".
final bool passedDividendGate;
final int? daysToNextExDividend;
/// Human-readable label for why this ISIN was in FinlyticTechnicals' scan universe in the first place (e.g.
/// "Nutzer-Favorit", "Sentiment-Spike"). `null` omits the row entirely - either the source model doesn't
/// carry this (manual-analysis result), or the evaluation happened outside the scan universe.
final String? universeSourceLabel;
/// When the ISIN entered that universe, shown alongside [universeSourceLabel]. Ignored if that is `null`.
final DateTime? universeEnteredAtUtc;
/// `null` omits the row entirely (manual-analysis result doesn't carry this gate).
final bool? passedSimulationVeto;
final String reasoningLabel;
final String reasoningText;
final List<String> identifiedRisks;
/// Optional extra content appended at the end (e.g. "→ Vorschlag XYZ entstand"
/// linkage row shown by the admin history sheet when `proposalId` is set).
final Widget? footer;
const EvaluationScoreBreakdownSheet({
super.key,
required this.scrollController,
required this.title,
required this.subtitle,
required this.headerIcon,
required this.headerColor,
required this.compositeScore,
required this.technicalScore,
required this.sentimentScore,
required this.fundamentalScore,
this.reliabilityBonus,
required this.passedEarningsLockout,
this.daysToNextEarnings,
this.passedDividendGate = true,
this.daysToNextExDividend,
this.universeSourceLabel,
this.universeEnteredAtUtc,
this.passedSimulationVeto,
required this.reasoningLabel,
required this.reasoningText,
this.identifiedRisks = const [],
this.footer,
});
/// Shows this sheet inside the shared `DraggableScrollableSheet` chrome
/// (rounded top corners, drag sizing) so both call sites get identical framing.
static Future<void> show(
BuildContext context, {
required String title,
required String subtitle,
required IconData headerIcon,
required Color headerColor,
required double compositeScore,
required double technicalScore,
required double sentimentScore,
required double fundamentalScore,
double? reliabilityBonus,
required bool passedEarningsLockout,
int? daysToNextEarnings,
bool passedDividendGate = true,
int? daysToNextExDividend,
String? universeSourceLabel,
DateTime? universeEnteredAtUtc,
bool? passedSimulationVeto,
required String reasoningLabel,
required String reasoningText,
List<String> identifiedRisks = const [],
Widget? footer,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.35,
maxChildSize: 0.9,
expand: false,
builder: (_, scrollController) {
return Container(
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
border: Border.all(color: AppTheme.glassBorder),
),
child: EvaluationScoreBreakdownSheet(
scrollController: scrollController,
title: title,
subtitle: subtitle,
headerIcon: headerIcon,
headerColor: headerColor,
compositeScore: compositeScore,
technicalScore: technicalScore,
sentimentScore: sentimentScore,
fundamentalScore: fundamentalScore,
reliabilityBonus: reliabilityBonus,
passedEarningsLockout: passedEarningsLockout,
daysToNextEarnings: daysToNextEarnings,
passedDividendGate: passedDividendGate,
daysToNextExDividend: daysToNextExDividend,
universeSourceLabel: universeSourceLabel,
universeEnteredAtUtc: universeEnteredAtUtc,
passedSimulationVeto: passedSimulationVeto,
reasoningLabel: reasoningLabel,
reasoningText: reasoningText,
identifiedRisks: identifiedRisks,
footer: footer,
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.all(20),
children: [
Row(
children: [
Icon(headerIcon, color: headerColor, size: 22),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white54),
),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(color: AppTheme.textMuted, fontSize: 12, height: 1.4),
),
const SizedBox(height: 18),
Text('SCORE-AUFSCHLÜSSELUNG', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 10),
Row(
children: [
_scoreTile('Composite', compositeScore),
const SizedBox(width: 8),
_scoreTile('Technisch', technicalScore),
const SizedBox(width: 8),
_scoreTile('Sentiment', sentimentScore),
const SizedBox(width: 8),
_scoreTile('Fundamental', fundamentalScore),
],
),
if (reliabilityBonus != null) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: SizedBox(width: 110, child: _bonusTile('Simulation-Bonus', reliabilityBonus!)),
),
],
if (universeSourceLabel != null) ...[
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.radar_outlined, size: 16, color: AppTheme.accentCyan),
const SizedBox(width: 8),
Expanded(
child: Text(
universeEnteredAtUtc != null
? 'In der Dauerbeobachtung seit ${_formatRelativeAge(universeEnteredAtUtc!)} als "$universeSourceLabel".'
: 'In der Dauerbeobachtung als "$universeSourceLabel".',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, height: 1.4),
),
),
],
),
],
if (daysToNextEarnings != null) ...[
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
passedEarningsLockout ? Icons.event_available_outlined : Icons.event_busy_outlined,
size: 16,
color: passedEarningsLockout ? AppTheme.textMuted : Colors.amber,
),
const SizedBox(width: 8),
Expanded(
child: Text(
passedEarningsLockout
? 'Nächste Quartalszahlen in $daysToNextEarnings Tagen keine Earnings-Sperre.'
: 'Earnings-Sperre aktiv: Quartalszahlen in nur $daysToNextEarnings Tagen.',
style: TextStyle(
color: passedEarningsLockout ? AppTheme.textMuted : Colors.amber,
fontSize: 12,
height: 1.4,
),
),
),
],
),
],
if (daysToNextExDividend != null) ...[
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
passedDividendGate ? Icons.event_available_outlined : Icons.event_busy_outlined,
size: 16,
color: passedDividendGate ? AppTheme.textMuted : Colors.amber,
),
const SizedBox(width: 8),
Expanded(
child: Text(
passedDividendGate
? 'Nächster Ex-Dividenden-Tag in $daysToNextExDividend Tag(en) keine Dividend-Sperre.'
: 'Dividend-Sperre aktiv: Ex-Dividenden-Tag in nur $daysToNextExDividend Tag(en).',
style: TextStyle(
color: passedDividendGate ? AppTheme.textMuted : Colors.amber,
fontSize: 12,
height: 1.4,
),
),
),
],
),
],
if (passedSimulationVeto != null) ...[
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
passedSimulationVeto! ? Icons.verified_outlined : Icons.gpp_bad_outlined,
size: 16,
color: passedSimulationVeto! ? AppTheme.textMuted : AppTheme.accentRed,
),
const SizedBox(width: 8),
Expanded(
child: Text(
passedSimulationVeto!
? 'Backtest-Reliability-Matrix hat kein Veto ausgesprochen.'
: 'Backtest-Reliability-Matrix hat diese Strategie/Asset-Kombination mit einem Veto belegt.',
style: TextStyle(
color: passedSimulationVeto! ? AppTheme.textMuted : AppTheme.accentRed,
fontSize: 12,
height: 1.4,
),
),
),
],
),
],
const SizedBox(height: 20),
Row(
children: [
const Icon(Icons.psychology, size: 16, color: Colors.purpleAccent),
const SizedBox(width: 8),
Text(
reasoningLabel,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
Text(
reasoningText.trim().isNotEmpty ? reasoningText : 'Keine weitere Begründung hinterlegt.',
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4),
),
if (identifiedRisks.isNotEmpty) ...[
const SizedBox(height: 16),
const Text('Identifizierte Risiken:', style: TextStyle(color: Colors.amber, fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
...identifiedRisks.map((r) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber, size: 12, color: Colors.amber),
const SizedBox(width: 6),
Expanded(child: Text(r, style: TextStyle(color: AppTheme.textMuted, fontSize: 12, height: 1.3))),
],
),
)),
],
if (footer != null) ...[
const SizedBox(height: 18),
footer!,
],
const SizedBox(height: 12),
],
);
}
/// Coarse "vor X Minuten/Stunden/Tagen" age string, relative to now. Deliberately coarse (no seconds) since
/// this is only ever used for a "how long has this been on the watchlist" hint, not a precise timestamp.
static String _formatRelativeAge(DateTime utc) {
final diff = DateTime.now().toUtc().difference(utc.toUtc());
if (diff.inMinutes < 1) return 'wenigen Sekunden';
if (diff.inMinutes < 60) return '${diff.inMinutes} Minute(n)';
if (diff.inHours < 24) return '${diff.inHours} Stunde(n)';
return '${diff.inDays} Tag(en)';
}
Widget _scoreTile(String label, double score) {
final color = score >= 70 ? AppTheme.primaryEmerald : (score >= 40 ? Colors.amber : AppTheme.accentRed);
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
children: [
Text(score.toStringAsFixed(0), style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 2),
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 9), textAlign: TextAlign.center),
],
),
),
);
}
Widget _bonusTile(String label, double bonus) {
final color = bonus > 0 ? AppTheme.primaryEmerald : AppTheme.textMuted;
return Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
children: [
Text('+${bonus.toStringAsFixed(0)}', style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 2),
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 9), textAlign: TextAlign.center),
],
),
);
}
}
@@ -4,8 +4,10 @@ import '../../core/network/api_client.dart';
import '../../core/network/signalr_service.dart';
import '../../core/theme/app_theme.dart';
import '../../core/theme/theme_cubit.dart';
import '../../features/admin/views/admin_evaluation_history_screen.dart';
import '../../features/admin/views/admin_users_screen.dart';
import '../../features/auth/models/user_model.dart';
import '../../features/bot/views/bot_control_panel_screen.dart';
import '../../features/calendar/views/corporate_calendar_screen.dart';
import '../../features/dashboard/views/dashboard_screen.dart';
import '../../features/discovery/cubit/discovery_cubit.dart';
@@ -13,6 +15,7 @@ import '../../features/favorites/cubit/favorites_cubit.dart';
import '../../features/favorites/views/favorites_screen.dart';
import '../../features/news/views/news_feed_screen.dart';
import '../../features/simulation/views/backtest_visualizer_screen.dart';
import '../../features/trades/views/trades_feed_screen.dart';
import 'global_app_bar.dart';
@@ -56,7 +59,10 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
'Favoriten',
'Kalender',
'Live Trades',
if (widget.user.isAdmin) 'Bot Panel',
if (widget.user.isAdmin) 'Backtest',
if (widget.user.isAdmin) 'Admin Panel',
if (widget.user.isAdmin) 'Evaluierungs-Historie',
];
@override
@@ -71,7 +77,10 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
FavoritesScreen(apiClient: widget.apiClient),
CorporateCalendarScreen(apiClient: widget.apiClient),
TradesFeedScreen(apiClient: widget.apiClient, signalRService: widget.signalRService),
if (widget.user.isAdmin) BotControlPanelScreen(apiClient: widget.apiClient, signalRService: widget.signalRService),
if (widget.user.isAdmin) BacktestVisualizerScreen(apiClient: widget.apiClient),
if (widget.user.isAdmin) AdminUsersScreen(apiClient: widget.apiClient, signalRService: widget.signalRService),
if (widget.user.isAdmin) AdminEvaluationHistoryScreen(apiClient: widget.apiClient),
];
return BlocBuilder<ThemeCubit, ThemeState>(
@@ -122,8 +131,14 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
const BottomNavigationBarItem(icon: Icon(Icons.star_rounded), label: 'Favoriten'),
const BottomNavigationBarItem(icon: Icon(Icons.calendar_month_rounded), label: 'Kalender'),
const BottomNavigationBarItem(icon: Icon(Icons.candlestick_chart_rounded), label: 'Trades'),
if (widget.user.isAdmin)
const BottomNavigationBarItem(icon: Icon(Icons.smart_toy_outlined), label: 'Bot'),
if (widget.user.isAdmin)
const BottomNavigationBarItem(icon: Icon(Icons.query_stats_rounded), label: 'Backtest'),
if (widget.user.isAdmin)
const BottomNavigationBarItem(icon: Icon(Icons.admin_panel_settings_outlined), label: 'Admin'),
if (widget.user.isAdmin)
const BottomNavigationBarItem(icon: Icon(Icons.history_rounded), label: 'Historie'),
],
),
);
@@ -158,7 +173,10 @@ class _DesktopSidebar extends StatelessWidget {
_navTile(activeTheme, 2, Icons.star_rounded, 'Favoriten'),
_navTile(activeTheme, 3, Icons.calendar_month_rounded, 'Kalender'),
_navTile(activeTheme, 4, Icons.candlestick_chart_rounded, 'Live Trades'),
if (isAdmin) _navTile(activeTheme, 5, Icons.admin_panel_settings_outlined, 'Admin Panel'),
if (isAdmin) _navTile(activeTheme, 5, Icons.smart_toy_outlined, 'Bot Panel'),
if (isAdmin) _navTile(activeTheme, 6, Icons.query_stats_rounded, 'Backtest'),
if (isAdmin) _navTile(activeTheme, 7, Icons.admin_panel_settings_outlined, 'Admin Panel'),
if (isAdmin) _navTile(activeTheme, 8, Icons.history_rounded, 'Evaluierungs-Historie'),
],
),
);