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
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
import '../../../bot/repositories/bot_repository.dart';
import '../../../proposals/views/proposal_decision_screen.dart';
import '../../../trades/models/trade_model.dart';
import '../../../trades/widgets/trade_execution_cockpit.dart';
import '../../../trades/widgets/trade_closing_cockpit.dart';
@@ -22,11 +26,12 @@ class TradesTab extends StatefulWidget {
}
class _TradesTabState extends State<TradesTab> {
bool _justTriggeredAnalysis = false;
late final BotRepository _botRepository;
@override
void initState() {
super.initState();
_botRepository = BotRepository(apiClient: context.read<ApiClient>());
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
@@ -39,8 +44,23 @@ class _TradesTabState extends State<TradesTab> {
defaultSymbol: widget.symbol,
isActive: isActive,
onAccept: (dto) {
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
final tId = trade.id;
// `TradeExecutionCockpit._buildDto()` already picks the right identifier
// (trade.id for isActive, trade.proposalId otherwise) and always fills
// actualEntryPrice/quantity from the two fields the dialog actually
// collects — but the two identifiers target different server-side
// operations: accepting a *proposal* vs. recording a fill against an
// already-*existing* trade (`UserTradesController.AcceptTrade` looks
// `dto.tradeId` up as a proposal id, which fails for an active trade's
// own id). Route accordingly instead of always calling AcceptTradeEvent.
if (isActive) {
final price = dto.actualEntryPrice ?? dto.entryPrice;
final qty = dto.quantity ?? dto.positionSize;
if (price == null || qty == null) return;
tradesBloc.add(AddTradeFillEvent(tId, widget.symbol, price, qty));
} else {
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
@@ -50,10 +70,15 @@ class _TradesTabState extends State<TradesTab> {
);
},
onReject: (tId) {
tradesBloc.add(RejectTradeEvent(tId, widget.symbol));
// Purely local dismissal — there is no server-side rejection (a
// proposal is a system-wide opportunity anyone may still accept).
// Wording must not claim a permanence the backend doesn't provide.
tradesBloc.add(DismissTradeEvent(tId));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade $tId abgelehnt.'),
content: const Text(
'Vorschlag ausgeblendet er kann beim nächsten Neuladen erneut erscheinen, bis er serverseitig abläuft.',
),
backgroundColor: AppTheme.textSecondary,
behavior: SnackBarBehavior.floating,
),
@@ -62,20 +87,101 @@ class _TradesTabState extends State<TradesTab> {
);
}
Future<void> _executeProposalViaBot(BuildContext context, TradeProposalModel proposal) async {
Navigator.of(context).pop();
try {
await _botRepository.executeProposal(proposal.proposalId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Vorschlag für ${proposal.symbol} an den Bot übergeben.'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler bei der Bot-Übergabe: $e'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
}
}
void _showProposalDecision(BuildContext context, TradeProposalModel proposal) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ProposalDecisionScreen(
proposal: proposal,
onExecuteBot: () => _executeProposalViaBot(context, proposal),
onManualTrade: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Manuelle Eröffnung: Bitte über die Order-Maske deines Brokers ausführen.'),
behavior: SnackBarBehavior.floating,
),
);
},
),
),
);
}
/// Shows the real, already-computed score breakdown and AI reasoning for a
/// manual analysis that ran but did not produce a trade proposal
/// ([AssetEvaluationResultModel.proposal] is `null`). Replaces the old bare
/// "kein Vorschlag" snackbar: the user gets to see *why* the opportunity
/// was rejected, not just *that* it was (Rules.md §4). Every value shown
/// here comes straight from the server response — nothing is invented, and
/// [AssetEvaluationResultModel.daysToNextEarnings] is only rendered when
/// the server actually sent a value.
void _showEvaluationRejectedSheet(BuildContext context, AssetEvaluationResultModel result) {
EvaluationScoreBreakdownSheet.show(
context,
title: 'Analyse abgeschlossen kein Vorschlag',
subtitle:
'Für ${widget.symbol} wurde keine aktive Trade-Empfehlung erzeugt. Die berechneten Werte und die KI-Begründung stehen unten.',
headerIcon: result.aiApproved ? Icons.psychology_outlined : Icons.block_outlined,
headerColor: result.aiApproved ? AppTheme.primaryEmerald : AppTheme.accentRed,
compositeScore: result.compositeScore,
technicalScore: result.technicalScore,
sentimentScore: result.sentimentScore,
fundamentalScore: result.fundamentalScore,
passedEarningsLockout: result.passedEarningsLockout,
daysToNextEarnings: result.daysToNextEarnings,
passedDividendGate: result.passedDividendGate,
daysToNextExDividend: result.daysToNextExDividend,
reasoningLabel: result.aiApproved ? 'KI-These' : 'Ablehnungsgrund',
reasoningText: result.aiThesisSummary,
identifiedRisks: result.aiIdentifiedRisks,
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
listener: (context, state) {
if (_justTriggeredAnalysis && state is AssetTradesLoaded) {
final List<TradeModel> tradesList = state.data;
if (tradesList.isNotEmpty) {
_justTriggeredAnalysis = false;
final latestTrade = tradesList.first;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showEditTradeExecutionDialog(context, latestTrade);
});
if (state is! AssetTradesLoaded) return;
final result = state.manualAnalysisResult;
if (result == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (result.hasProposal) {
_showProposalDecision(context, result.proposal!);
} else {
// Rejected (or no technical setup at all) - show the real, already
// computed scores and AI reasoning instead of a bare "no proposal"
// snackbar, so the user understands *why*, not just *that*
// (Rules.md §4).
_showEvaluationRejectedSheet(context, result);
}
}
});
},
builder: (context, state) {
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
@@ -117,11 +223,10 @@ class _TradesTabState extends State<TradesTab> {
symbol: widget.symbol,
initialRiskScore: 50.0,
onTrigger: (payload) {
setState(() => _justTriggeredAnalysis = true);
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('KI-Analyse für ${widget.symbol} abgeschlossen. Trade-Cockpit öffnet sich...'),
content: Text('KI-Analyse für ${widget.symbol} wird ausgeführt...'),
backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating,
),
@@ -153,18 +258,12 @@ class _TradesTabState extends State<TradesTab> {
else if (state is AssetTradesLoaded) ...[
_buildTradeList(
'Aktive Trade-Signale & Positionen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'ACTIVE' || s == 'PENDING' || s == 'PROPOSED';
}).toList(),
tradesList.where((t) => t.isActive || t.isProposed).toList(),
),
const SizedBox(height: 20),
_buildTradeList(
'Historische Trades & KI-Bewertungen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'CLOSED' || s == 'REJECTED' || (s != 'ACTIVE' && s != 'PENDING' && s != 'PROPOSED');
}).toList(),
tradesList.where((t) => t.isClosed || t.isRejected).toList(),
),
],
],
@@ -208,8 +307,7 @@ class _TradesTabState extends State<TradesTab> {
itemCount: trades.length,
itemBuilder: (context, index) {
final trade = trades[index];
final s = trade.status.toUpperCase();
final isActive = s == 'ACTIVE';
final isActive = trade.isActive;
return AssetTradeItemCard(
trade: trade,
@@ -223,7 +321,7 @@ class _TradesTabState extends State<TradesTab> {
trade: trade,
defaultSymbol: widget.symbol,
onClose: (dto) {
final isinVal = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
final isinVal = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : widget.symbol;
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(