feat(trades): add live execution cockpit, closing cockpit, calculation cards and precision trade settings
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user