345 lines
15 KiB
Dart
345 lines
15 KiB
Dart
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';
|
||
import '../../bloc/trades/asset_trades_bloc.dart';
|
||
import '../../bloc/trades/asset_trades_event.dart';
|
||
import '../../bloc/trades/asset_trades_state.dart';
|
||
import '../../widgets/trades/manual_analysis_dialog.dart';
|
||
import '../../widgets/trades/asset_trade_item_card.dart';
|
||
|
||
class TradesTab extends StatefulWidget {
|
||
final String symbol;
|
||
const TradesTab({super.key, required this.symbol});
|
||
|
||
@override
|
||
State<TradesTab> createState() => _TradesTabState();
|
||
}
|
||
|
||
class _TradesTabState extends State<TradesTab> {
|
||
late final BotRepository _botRepository;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_botRepository = BotRepository(apiClient: context.read<ApiClient>());
|
||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
|
||
}
|
||
|
||
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
|
||
final tradesBloc = context.read<AssetTradesBloc>();
|
||
|
||
TradeExecutionCockpit.show(
|
||
context,
|
||
trade: trade,
|
||
defaultSymbol: widget.symbol,
|
||
isActive: isActive,
|
||
onAccept: (dto) {
|
||
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!'),
|
||
backgroundColor: AppTheme.primaryEmerald,
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
},
|
||
onReject: (tId) {
|
||
// 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: const Text(
|
||
'Vorschlag ausgeblendet – er kann beim nächsten Neuladen erneut erscheinen, bis er serverseitig abläuft.',
|
||
),
|
||
backgroundColor: AppTheme.textSecondary,
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
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 (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 : [];
|
||
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
GlassContainer(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('Trade & Signal Management', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Colors.white)),
|
||
const SizedBox(height: 4),
|
||
Text('KI-gestützte technische & fundamentale Trade-Analyse anfordern', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
StatusBadge(label: widget.symbol, color: AppTheme.accentCyan),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton.icon(
|
||
onPressed: () {
|
||
ManualAnalysisDialog.show(
|
||
context,
|
||
symbol: widget.symbol,
|
||
initialRiskScore: 50.0,
|
||
onTrigger: (payload) {
|
||
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('KI-Analyse für ${widget.symbol} wird ausgeführt...'),
|
||
backgroundColor: AppTheme.accentCyan,
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
icon: const Icon(Icons.auto_awesome, size: 18),
|
||
label: const Text('KI-Analyse Starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: AppTheme.accentCyan,
|
||
foregroundColor: Colors.black,
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
if (state is AssetTradesLoading)
|
||
_buildTradesShimmer(context)
|
||
else if (state is AssetTradesError)
|
||
GlassContainer(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Text('Fehler: ${state.message}', style: TextStyle(color: AppTheme.accentRed)),
|
||
)
|
||
else if (state is AssetTradesLoaded) ...[
|
||
_buildTradeList(
|
||
'Aktive Trade-Signale & Positionen',
|
||
tradesList.where((t) => t.isActive || t.isProposed).toList(),
|
||
),
|
||
const SizedBox(height: 20),
|
||
_buildTradeList(
|
||
'Historische Trades & KI-Bewertungen',
|
||
tradesList.where((t) => t.isClosed || t.isRejected).toList(),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildTradesShimmer(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const ShimmerLoading(width: 260, height: 20, borderRadius: 6),
|
||
const SizedBox(height: 12),
|
||
for (int i = 0; i < 3; i++) ...[
|
||
const ShimmerLoading(width: double.infinity, height: 105, borderRadius: 14),
|
||
const SizedBox(height: 12),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildTradeList(String title, List<TradeModel> trades) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||
const SizedBox(height: 10),
|
||
if (trades.isEmpty)
|
||
GlassContainer(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Center(
|
||
child: Text('Keine Trades in dieser Kategorie vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||
),
|
||
)
|
||
else
|
||
ListView.builder(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
itemCount: trades.length,
|
||
itemBuilder: (context, index) {
|
||
final trade = trades[index];
|
||
final isActive = trade.isActive;
|
||
|
||
return AssetTradeItemCard(
|
||
trade: trade,
|
||
defaultSymbol: widget.symbol,
|
||
onAccept: () => _showEditTradeExecutionDialog(context, trade),
|
||
onSettings: () => _showEditTradeExecutionDialog(context, trade, isActive: true),
|
||
onClose: isActive
|
||
? () {
|
||
TradeClosingCockpit.show(
|
||
context,
|
||
trade: trade,
|
||
defaultSymbol: widget.symbol,
|
||
onClose: (dto) {
|
||
final isinVal = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : widget.symbol;
|
||
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Trade ${trade.id} geschlossen! Realisierter Ausstiegskurs: €${dto.userExitPrice.toStringAsFixed(2)}'),
|
||
backgroundColor: AppTheme.primaryEmerald,
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
: null,
|
||
);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|