feat(trades): add live execution cockpit, closing cockpit, calculation cards and precision trade settings

This commit is contained in:
2026-08-15 19:30:25 +02:00
parent 882d24a316
commit 34fa774cbf
31 changed files with 4235 additions and 630 deletions
@@ -0,0 +1,473 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../models/trade_model.dart';
import '../models/close_trade_request_dto.dart';
class TradeClosingCockpit extends StatefulWidget {
final TradeModel trade;
final String defaultSymbol;
final void Function(CloseTradeRequestDto) onClose;
const TradeClosingCockpit({
super.key,
required this.trade,
required this.defaultSymbol,
required this.onClose,
});
static Future<void> show(
BuildContext context, {
required TradeModel trade,
required String defaultSymbol,
required void Function(CloseTradeRequestDto) onClose,
}) {
return showDialog(
context: context,
barrierDismissible: true,
builder: (dialogContext) => TradeClosingCockpit(
trade: trade,
defaultSymbol: defaultSymbol,
onClose: onClose,
),
);
}
@override
State<TradeClosingCockpit> createState() => _TradeClosingCockpitState();
}
class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
late TextEditingController _exitPriceCtrl;
late TextEditingController _exitFeeCtrl;
late TextEditingController _notesCtrl;
DateTime _exitTimestamp = DateTime.now();
String _selectedReasonTag = 'Manuell in TR verkauft';
@override
void initState() {
super.initState();
final defaultPrice = widget.trade.currentPrice > 0
? widget.trade.currentPrice
: (widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : widget.trade.entryPrice);
_exitPriceCtrl = TextEditingController(text: defaultPrice.toStringAsFixed(2));
_exitFeeCtrl = TextEditingController(text: '1.00');
_notesCtrl = TextEditingController();
_exitPriceCtrl.addListener(() => setState(() {}));
_exitFeeCtrl.addListener(() => setState(() {}));
}
@override
void dispose() {
_exitPriceCtrl.dispose();
_exitFeeCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
double _parse(TextEditingController ctrl, double fallback) {
final clean = ctrl.text.replaceAll(',', '.').trim();
return double.tryParse(clean) ?? fallback;
}
double get _exitPrice => _parse(_exitPriceCtrl, widget.trade.entryPrice);
double get _exitFee => _parse(_exitFeeCtrl, 1.0);
double get _entryPrice => widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : (widget.trade.entryPrice > 0 ? widget.trade.entryPrice : 1.0);
double get _posSize => widget.trade.positionSize > 0 ? widget.trade.positionSize : 1000.0;
double get _quantity => widget.trade.quantity > 0 ? widget.trade.quantity : (_posSize / _entryPrice);
double get _calculatedProceeds {
if (_exitPrice <= 0 || _quantity <= 0) return 0.0;
return _quantity * _exitPrice;
}
double get _calculatedPnlAbs {
if (_entryPrice <= 0 || _exitPrice <= 0) return 0.0;
final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT';
final movePct = isShort ? ((_entryPrice - _exitPrice) / _entryPrice) : ((_exitPrice - _entryPrice) / _entryPrice);
final lev = (widget.trade.instrumentType.toLowerCase().contains('knock') || widget.trade.instrumentType.toLowerCase().contains('option'))
? 1.0
: (widget.trade.leverageUsed > 0 ? widget.trade.leverageUsed : 1.0);
final totalFees = (widget.trade.entryFee > 0 ? widget.trade.entryFee : 1.0) + _exitFee;
return (movePct * _posSize * lev) - totalFees;
}
double get _calculatedPnlPct {
if (_posSize <= 0) return 0.0;
return (_calculatedPnlAbs / _posSize) * 100.0;
}
void _selectTimeOption(String option) {
final now = DateTime.now();
setState(() {
if (option == 'now') {
_exitTimestamp = now;
} else if (option == 'today_morning') {
_exitTimestamp = DateTime(now.year, now.month, now.day, 9, 15);
} else if (option == 'today_noon') {
_exitTimestamp = DateTime(now.year, now.month, now.day, 13, 0);
} else if (option == 'yesterday') {
final yest = now.subtract(const Duration(days: 1));
_exitTimestamp = DateTime(yest.year, yest.month, yest.day, 17, 30);
}
});
}
Future<void> _pickCustomDateTime() async {
final pickedDate = await showDatePicker(
context: context,
initialDate: _exitTimestamp,
firstDate: DateTime.now().subtract(const Duration(days: 90)),
lastDate: DateTime.now(),
);
if (pickedDate != null && mounted) {
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_exitTimestamp),
);
if (pickedTime != null && mounted) {
setState(() {
_exitTimestamp = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
});
}
}
}
@override
Widget build(BuildContext context) {
final isWin = _calculatedPnlAbs >= 0;
final pnlColor = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Container(
width: 580,
constraints: const BoxConstraints(maxHeight: 780),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppTheme.glassBorder),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.6), blurRadius: 30, offset: const Offset(0, 10)),
],
),
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 16, 14),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.accentRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.flag_outlined, color: AppTheme.accentRed, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Position Schließen & Nacherfassen',
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
Text(
'Trade #${widget.trade.id}${widget.trade.companyName.isNotEmpty ? widget.trade.companyName : widget.defaultSymbol}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white54),
),
],
),
),
const Divider(color: Colors.white12, height: 1),
// Body
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Entry Recap Box
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_summaryCol('Einstiegskurs', '${_entryPrice.toStringAsFixed(2)}'),
_summaryCol('Investition', '${_posSize.toStringAsFixed(0)}'),
_summaryCol('Stückzahl', '${_quantity.toStringAsFixed(2)} Stk.'),
_summaryCol('Instrument', widget.trade.instrumentType),
],
),
),
const SizedBox(height: 18),
// SECTION 1: VERKAUFSKURS
const Text('1. TATSÄCHLICHER VERKAUFSKURS (€)', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _exitPriceCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.sell_outlined, size: 18, color: Colors.white54),
labelText: 'Verkaufskurs in Trade Republic',
filled: true,
fillColor: AppTheme.glassSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _exitFeeCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.receipt_long, size: 16, color: Colors.white54),
labelText: 'Ausstiegsgebühr (€)',
filled: true,
fillColor: AppTheme.glassSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)),
),
),
),
],
),
const SizedBox(height: 18),
// SECTION 2: ZEITPUNKT
const Text('2. WANN WURDE DER TRADE GESCHLOSSEN?', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_timeChip('Jetzt', 'now'),
_timeChip('Heute Morgen (09:15)', 'today_morning'),
_timeChip('Heute Mittag (13:00)', 'today_noon'),
_timeChip('Gestern (17:30)', 'yesterday'),
],
),
),
const SizedBox(height: 8),
// Custom Date/Time Picker Trigger
GestureDetector(
onTap: _pickCustomDateTime,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.calendar_today, size: 16, color: AppTheme.accentCyan),
const SizedBox(width: 8),
Text(
'Ausführungszeit: ${_formatDateTime(_exitTimestamp)}',
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
],
),
Text('Ändern', style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold)),
],
),
),
),
const SizedBox(height: 18),
// SECTION 3: GRUND / NOTIZ
const Text('3. AUSSTIEGSGRUND', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_reasonChip('🎯 Take-Profit gegriffen'),
_reasonChip('📱 Manuell in TR verkauft'),
_reasonChip('🛑 Stop-Loss ausgelöst'),
_reasonChip('🕒 Vor Wochenende / Time-Stop'),
_reasonChip('⚠️ Risiko minimiert'),
],
),
const SizedBox(height: 20),
// LIVE REALISIERTER PNL VORSCHAU
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: pnlColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: pnlColor.withValues(alpha: 0.4)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Realisierter Gewinn / Verlust (PnL):', style: const TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)),
Text(
'${(isWin ? "+€" : "-€")}${_calculatedPnlAbs.abs().toStringAsFixed(2)} (${isWin ? "+" : ""}${_calculatedPnlPct.toStringAsFixed(2)}%)',
style: TextStyle(color: pnlColor, fontSize: 17, fontWeight: FontWeight.w900),
),
],
),
const Divider(color: Colors.white12, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Verkaufserlös (Gesamt): €${_calculatedProceeds.toStringAsFixed(2)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
Text('Netto nach Gebühren: €${(_posSize + _calculatedPnlAbs).toStringAsFixed(2)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
],
),
),
],
),
),
),
// Actions Footer
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
const Spacer(),
ElevatedButton.icon(
onPressed: () {
final req = CloseTradeRequestDto(
userExitPrice: _exitPrice,
userExitTimestamp: _exitTimestamp,
exitFee: _exitFee,
closeReason: _selectedReasonTag,
);
Navigator.pop(context);
widget.onClose(req);
},
icon: const Icon(Icons.check_circle, size: 18),
label: const Text('Position Exakt So Buchen', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
style: ElevatedButton.styleFrom(
backgroundColor: pnlColor,
foregroundColor: isWin ? Colors.black : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
),
],
),
),
);
}
Widget _summaryCol(String label, String val) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(val, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)),
],
);
}
Widget _timeChip(String label, String option) {
return GestureDetector(
onTap: () => _selectTimeOption(option),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.bold)),
),
);
}
Widget _reasonChip(String label) {
final isSelected = _selectedReasonTag == label;
return GestureDetector(
onTap: () => setState(() => _selectedReasonTag = label),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? AppTheme.accentCyan.withValues(alpha: 0.2) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isSelected ? AppTheme.accentCyan : AppTheme.glassBorder),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? AppTheme.accentCyan : Colors.white70,
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
String _formatDateTime(DateTime dt) {
final d = dt.day.toString().padLeft(2, '0');
final m = dt.month.toString().padLeft(2, '0');
final y = dt.year;
final h = dt.hour.toString().padLeft(2, '0');
final min = dt.minute.toString().padLeft(2, '0');
return '$d.$m.$y um $h:$min Uhr';
}
}