Files
Finlytic/FinlyticApp/lib/features/trades/widgets/trade_card.dart
T

398 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../models/trade_model.dart';
import 'trade_calculation_card.dart';
import 'trade_detail_modal.dart';
/// Migrated onto `ActiveTradeDto`. `trade.currentPrice` is the engine's own
/// tracked live price, so this card no longer needs to cross-reference the
/// favorites feed for a "live" quote — doing so would just be a second,
/// possibly-stale source of truth for a number the trade payload already
/// carries. The old "is this actually a derivative quote or the underlying's"
/// heuristic (comparing `entryPrice`/`actualEntryPrice` magnitudes) is gone
/// too: there is only one entry price now (`averageBuyIn`, the real
/// fill-weighted average), so there is nothing left to disambiguate.
class TradeCard extends StatelessWidget {
final TradeModel trade;
final VoidCallback? onAccept;
final VoidCallback? onClose;
final VoidCallback? onSettings;
const TradeCard({
super.key,
required this.trade,
this.onAccept,
this.onClose,
this.onSettings,
});
String _fmt(double val) => val.toStringAsFixed(2);
@override
Widget build(BuildContext context) {
final isBuy = trade.direction.isLong;
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
final isProposed = trade.isProposed;
final isActive = trade.isActive;
final isClosed = trade.isClosed;
// Server-computed, never recalculated client-side (Rules.md: don't
// re-derive P&L — `pnlEur` picks realized vs. unrealized, `
// unrealizedPnlPercent` is populated by the engine for both open and
// closed trades since `CurrentPrice` is pinned to the close price once
// a trade is closed).
final pnlAbs = trade.pnlEur;
final pnlPct = trade.unrealizedPnlPercent;
final isPnlPos = pnlAbs >= 0;
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
final currPrice = trade.currentPrice;
final tpStages = trade.exitPlan.takeProfitStages;
final primaryTp = trade.primaryTakeProfit;
return GlassContainer(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row: Signal, Symbol, Drift-Radar & Live PnL
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: signalColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: signalColor.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isBuy ? Icons.trending_up : Icons.trending_down, size: 14, color: signalColor),
const SizedBox(width: 4),
Text(trade.direction.label, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
trade.symbol.isNotEmpty ? trade.symbol : (trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : 'Position'),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(4),
),
child: Text(trade.instrumentType.label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.bold)),
),
],
),
const SizedBox(height: 2),
Text(
'${trade.symbol.isNotEmpty ? trade.symbol : ""}${trade.underlyingIsin.isNotEmpty ? "${trade.underlyingIsin}" : ""}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
],
),
),
// Status / PnL / Drift-Radar Badge
if (isActive || isClosed) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: pnlColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: pnlColor.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isPnlPos ? '+' : ''}${pnlAbs.abs().toStringAsFixed(2)}',
style: TextStyle(color: pnlColor, fontWeight: FontWeight.w900, fontSize: 14),
),
Text(
'${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%',
style: TextStyle(color: pnlColor, fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
),
] else if (trade.isRejected) ...[
StatusBadge(label: trade.status.label, color: AppTheme.accentRed),
] else ...[
StatusBadge(label: trade.status.label, color: Colors.amber),
],
],
),
// Active Drift Radar / Trailing Alert Indicator
if (isActive) ...[
const SizedBox(height: 10),
_buildDriftRadarBar(trade),
],
const SizedBox(height: 12),
// Price Metrics Grid with Live Kurs & Trailing SL
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_priceItem('Einstieg', trade.averageBuyIn > 0 ? '${_fmt(trade.averageBuyIn)}' : '', Colors.white),
_priceItem('Live-Kurs', currPrice > 0 ? '${_fmt(currPrice)}' : '', AppTheme.accentCyan),
_priceItem(
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
'${_fmt(trade.currentStopLoss)}',
AppTheme.accentRed,
),
_priceItem(
tpStages.length > 1 ? 'TP (1. Stufe)' : 'Take-Profit',
primaryTp != null ? '${_fmt(primaryTp)}' : 'Trailing-Exit',
AppTheme.primaryEmerald,
),
],
),
),
if (tpStages.length > 1) ...[
const SizedBox(height: 8),
Row(
children: [
Text(
'Ziele: ',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
),
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 4,
children: tpStages.map((stage) {
final isCurrent = primaryTp != null && (primaryTp - stage.targetPrice).abs() < 0.01;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: isCurrent
? AppTheme.primaryEmerald.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isCurrent
? AppTheme.primaryEmerald
: Colors.white.withValues(alpha: 0.15),
),
),
child: Text(
'TP${stage.stageNumber}: €${_fmt(stage.targetPrice)}',
style: TextStyle(
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
fontSize: 10.5,
fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold,
),
),
);
}).toList(),
),
),
],
),
],
// Execution history (replaces the removed AI-Guardian hourly
// check-in timeline, which no backend DTO produces anymore — this
// is the trade's real fill history instead).
if (trade.fills.isNotEmpty) ...[
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 6),
dense: true,
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
title: Text(
'Ausführungshistorie (${trade.fills.length} Fills)',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
),
children: trade.fills.reversed.take(4).map((f) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Text(
'${f.executedAtUtc.hour.toString().padLeft(2, '0')}:${f.executedAtUtc.minute.toString().padLeft(2, '0')}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
),
],
const SizedBox(height: 8),
// Collapsible Live-Kalkulation & TP-Multi-Target Card
TradeCalculationCard(trade: trade, isCollapsible: true, initiallyExpanded: false),
const SizedBox(height: 12),
// Footer Action Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
trade.exitPlan.strategyType.label,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
Row(
children: [
IconButton(
onPressed: () => TradeDetailModal.show(
context,
trade: trade,
onAccept: onAccept,
onClose: onClose,
),
icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan),
tooltip: 'Details',
),
if (isProposed && onAccept != null) ...[
const SizedBox(width: 6),
ElevatedButton.icon(
onPressed: onAccept,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('Trade Übernehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
],
if (isActive && onClose != null) ...[
const SizedBox(width: 6),
OutlinedButton.icon(
onPressed: onClose,
icon: Icon(Icons.flag_outlined, size: 14, color: AppTheme.accentRed),
label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12, fontWeight: FontWeight.bold)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
],
if (isActive && onSettings != null) ...[
const SizedBox(width: 6),
IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
tooltip: 'Einstellungen anpassen',
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
),
),
],
],
),
],
),
],
),
);
}
Widget _buildDriftRadarBar(TradeModel t) {
Color col;
String label;
IconData icon;
switch (t.driftStatus) {
case DriftStatus.trailingActive:
col = AppTheme.accentCyan;
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
icon = Icons.security;
break;
case DriftStatus.driftWarning:
col = Colors.orangeAccent;
label = 'Drift-Radar: Leichte Abweichung von Prognose';
icon = Icons.tune;
break;
case DriftStatus.onTrack:
col = AppTheme.primaryEmerald;
label = 'Drift-Radar: Prognose intakt';
icon = Icons.radar;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: col.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: col.withValues(alpha: 0.25)),
),
child: Row(
children: [
Icon(icon, color: col, size: 14),
const SizedBox(width: 6),
Expanded(
child: Text(label, style: TextStyle(color: col, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
);
}
Widget _priceItem(String label, String val, Color valColor, {String? subtitle}) {
return Column(
children: [
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(val, style: TextStyle(color: valColor, fontWeight: FontWeight.bold, fontSize: 12)),
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 1),
Text(subtitle, style: TextStyle(color: AppTheme.accentCyan, fontSize: 9, fontWeight: FontWeight.w600)),
],
],
);
}
}