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