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