345 lines
15 KiB
Dart
345 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../../../../core/theme/app_theme.dart';
|
||
import '../../../../core/widgets/status_badge.dart';
|
||
import '../models/trade_model.dart';
|
||
import '../models/trade_acceptance_dto.dart';
|
||
import 'trade_execution_ai_plan_card.dart';
|
||
import 'trade_calculation_card.dart';
|
||
|
||
/// Dialog for either (a) confirming the real execution of a trade proposal
|
||
/// (the AI-computed plan — instrument, exit strategy, stop-loss, take-profit
|
||
/// stages — is shown read-only via [TradeExecutionAiPlanCard]/
|
||
/// [TradeCalculationCard]; only the price/quantity the user actually got
|
||
/// filled at are editable), or (b) — when [isActive] is `true` — reviewing an
|
||
/// already-open trade's execution numbers.
|
||
///
|
||
/// This used to also let the user redesign the whole trade client-side
|
||
/// (instrument type, derivative pick, leverage, entry/exit fees, a
|
||
/// client-computed CRV). None of that survived the `ActiveTradeDto` /
|
||
/// `TradeAcceptanceDto` migration: the engine now picks the instrument and
|
||
/// derivative itself (`TradeProposalDto.selectedDerivative`), computes the
|
||
/// full exit plan server-side (`ExitPlan`), and
|
||
/// `UserTradesController.AcceptTrade` only ever reads
|
||
/// `ActualEntryPrice`/`EntryPrice` and `Quantity`/`PositionSize` from the
|
||
/// payload — every other field the old cockpit built (leverage, fees,
|
||
/// instrument type, derivative ISIN, timeframe, reasoning) is silently
|
||
/// ignored server-side today. Rebuilding client-side leverage/fee math for
|
||
/// fields the backend no longer accepts would be exactly the kind of
|
||
/// disconnected-from-reality UI Rules.md §4 forbids, so that whole surface
|
||
/// was removed rather than kept alive against fields that don't exist
|
||
/// anymore (Rules.md §3/§4).
|
||
class TradeExecutionCockpit extends StatefulWidget {
|
||
final TradeModel trade;
|
||
final String defaultSymbol;
|
||
final bool isActive;
|
||
final Function(TradeAcceptanceDto dto) onAccept;
|
||
final Function(String tradeId)? onReject;
|
||
|
||
const TradeExecutionCockpit({
|
||
super.key,
|
||
required this.trade,
|
||
required this.defaultSymbol,
|
||
this.isActive = false,
|
||
required this.onAccept,
|
||
this.onReject,
|
||
});
|
||
|
||
static Future<void> show(
|
||
BuildContext context, {
|
||
required TradeModel trade,
|
||
required String defaultSymbol,
|
||
bool isActive = false,
|
||
required Function(TradeAcceptanceDto dto) onAccept,
|
||
Function(String tradeId)? onReject,
|
||
}) {
|
||
return showDialog(
|
||
context: context,
|
||
barrierDismissible: true,
|
||
builder: (dialogContext) => TradeExecutionCockpit(
|
||
trade: trade,
|
||
defaultSymbol: defaultSymbol,
|
||
isActive: isActive,
|
||
onAccept: onAccept,
|
||
onReject: onReject,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
State<TradeExecutionCockpit> createState() => _TradeExecutionCockpitState();
|
||
}
|
||
|
||
class _TradeExecutionCockpitState extends State<TradeExecutionCockpit> {
|
||
late TextEditingController _entryPriceCtrl;
|
||
late TextEditingController _quantityCtrl;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
// Both fields are prefilled from real, already-known server data where
|
||
// available — never from an invented placeholder (Rules.md §4). For an
|
||
// already-active trade both numbers are simply the engine's own record
|
||
// of what happened (`averageBuyIn`/`totalQuantity`). For a still-proposed
|
||
// trade there is no filled quantity yet, so that field starts empty and
|
||
// the user must supply the real number — showing e.g. a default of 1000
|
||
// would look like a real suggested size when it is not.
|
||
final prefillPrice = widget.isActive
|
||
? widget.trade.averageBuyIn
|
||
: (widget.trade.currentPrice > 0 ? widget.trade.currentPrice : widget.trade.averageBuyIn);
|
||
|
||
_entryPriceCtrl = TextEditingController(text: prefillPrice > 0 ? prefillPrice.toStringAsFixed(2) : '');
|
||
_quantityCtrl = TextEditingController(
|
||
text: widget.isActive && widget.trade.totalQuantity > 0 ? widget.trade.totalQuantity.toString() : '',
|
||
);
|
||
|
||
_entryPriceCtrl.addListener(() => setState(() {}));
|
||
_quantityCtrl.addListener(() => setState(() {}));
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_entryPriceCtrl.dispose();
|
||
_quantityCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
double? _parsePositive(TextEditingController ctrl) {
|
||
final clean = ctrl.text.replaceAll(',', '.').trim();
|
||
final val = double.tryParse(clean);
|
||
return (val != null && val > 0) ? val : null;
|
||
}
|
||
|
||
double? get _entryPrice => _parsePositive(_entryPriceCtrl);
|
||
double? get _quantity => _parsePositive(_quantityCtrl);
|
||
|
||
/// Straightforward price × quantity of the two values the user is about to
|
||
/// submit — not a P&L figure and not derived from any server P&L field, so
|
||
/// recomputing it client-side does not create a second, competing P&L
|
||
/// calculation (Rules.md's "don't recompute P&L" concern is about
|
||
/// `unrealizedPnlEur`/`realizedPnlEur`, which this never touches).
|
||
double? get _investedCapital {
|
||
final p = _entryPrice;
|
||
final q = _quantity;
|
||
return (p != null && q != null) ? p * q : null;
|
||
}
|
||
|
||
bool get _canSubmit => _entryPrice != null && _quantity != null;
|
||
|
||
TradeAcceptanceDto _buildDto() {
|
||
// For an accept action the payload identifier must be the *proposal* id
|
||
// (`UserTradesController.AcceptTrade` parses `tradeId` as a proposal
|
||
// Guid and calls `engine_AcceptProposal`), which is `trade.proposalId`
|
||
// here — not `trade.id` (the already-existing `ActiveTradeDto.TradeId`).
|
||
// For the settings-review path on an already-active trade there is no
|
||
// separate proposal to target, so the trade's own id is used instead.
|
||
final identifier = widget.isActive ? widget.trade.id : widget.trade.proposalId;
|
||
final isinVal = widget.trade.underlyingIsin.isNotEmpty ? widget.trade.underlyingIsin : widget.defaultSymbol;
|
||
final symbolVal = widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol;
|
||
|
||
return TradeAcceptanceDto(
|
||
userId: '', // Discarded server-side in favor of the JWT identity anyway.
|
||
tradeId: identifier,
|
||
isin: isinVal,
|
||
symbol: symbolVal,
|
||
actualEntryPrice: _entryPrice,
|
||
quantity: _quantity,
|
||
executionTimestamp: DateTime.now().toUtc(),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isLong = widget.trade.direction.isLong;
|
||
final signalColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||
|
||
return Dialog(
|
||
backgroundColor: Colors.transparent,
|
||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||
child: Container(
|
||
width: 560,
|
||
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: [
|
||
StatusBadge(label: widget.trade.direction.label, color: signalColor),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
widget.isActive ? 'Ausführungsdetails – Trade #${widget.trade.id}' : '1-Click Trade Execution Cockpit',
|
||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||
),
|
||
Text(
|
||
'${widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol} (${widget.trade.underlyingIsin.isNotEmpty ? widget.trade.underlyingIsin : 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),
|
||
|
||
// Scrollable Content
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Read-only AI-computed plan (instrument, exit strategy,
|
||
// stop-loss, take-profit stages — all real server fields).
|
||
TradeExecutionAiPlanCard(trade: widget.trade),
|
||
const SizedBox(height: 12),
|
||
TradeCalculationCard(trade: widget.trade, isCollapsible: false),
|
||
const SizedBox(height: 18),
|
||
|
||
Text(
|
||
widget.isActive ? 'AUSFÜHRUNGSDATEN DIESES TRADES' : 'DEINE TATSÄCHLICHE AUSFÜHRUNG',
|
||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5),
|
||
),
|
||
const SizedBox(height: 10),
|
||
|
||
Row(
|
||
children: [
|
||
Expanded(child: _buildInput('Ausführungskurs (€)', _entryPriceCtrl, Icons.price_change)),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: _buildInput('Stückzahl', _quantityCtrl, Icons.numbers)),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
_investedCapital != null
|
||
? 'Eingesetztes Kapital: €${_investedCapital!.toStringAsFixed(2)}'
|
||
: 'Bitte Ausführungskurs und Stückzahl angeben.',
|
||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||
),
|
||
|
||
const SizedBox(height: 18),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.amber.withValues(alpha: 0.05),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: Colors.amber.withValues(alpha: 0.25)),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(Icons.gavel_outlined, size: 16, color: Colors.amber.withValues(alpha: 0.85)),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
'Rechtlicher Hinweis: Keine Anlageberatung. Sämtliche Analysen, Kennzahlen und Simulationen dienen ausschließlich Informations- und Bildungszwecken. Der Handel mit Hebelprodukten (Derivate, CFDs) birgt erhebliche Risiken bis hin zum Totalverlust des eingesetzten Kapitals.',
|
||
style: TextStyle(color: AppTheme.textMuted, fontSize: 10.5, height: 1.35),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// 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(),
|
||
if (!widget.isActive && widget.onReject != null) ...[
|
||
// This only hides the card locally — the proposal is a
|
||
// system-wide opportunity anyone may still accept, and
|
||
// there is no server-side rejection anymore. Label and
|
||
// color must not suggest an irreversible/destructive
|
||
// action that doesn't actually happen (Rules.md §4).
|
||
OutlinedButton.icon(
|
||
onPressed: () {
|
||
widget.onReject!(widget.trade.id);
|
||
Navigator.pop(context);
|
||
},
|
||
icon: Icon(Icons.visibility_off_outlined, color: AppTheme.textMuted, size: 16),
|
||
label: Text('Verwerfen', style: TextStyle(color: AppTheme.textMuted)),
|
||
style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.textMuted.withValues(alpha: 0.5))),
|
||
),
|
||
const SizedBox(width: 10),
|
||
],
|
||
ElevatedButton.icon(
|
||
onPressed: _canSubmit
|
||
? () {
|
||
final dto = _buildDto();
|
||
widget.onAccept(dto);
|
||
Navigator.pop(context);
|
||
}
|
||
: null,
|
||
icon: Icon(widget.isActive ? Icons.save : Icons.check_circle, size: 18),
|
||
label: Text(
|
||
widget.isActive ? 'Ausführung Speichern' : 'Trade Jetzt Eröffnen',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||
),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: widget.isActive ? AppTheme.accentCyan : AppTheme.primaryEmerald,
|
||
foregroundColor: Colors.black,
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInput(String label, TextEditingController controller, IconData icon) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 6),
|
||
TextField(
|
||
controller: controller,
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||
decoration: InputDecoration(
|
||
prefixIcon: Icon(icon, size: 16, color: Colors.white54),
|
||
filled: true,
|
||
fillColor: AppTheme.glassSurface,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)),
|
||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)),
|
||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.accentCyan)),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|