Files

345 lines
15 KiB
Dart
Raw Permalink 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/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)),
),
),
],
);
}
}