feat(trades): add live execution cockpit, closing cockpit, calculation cards and precision trade settings
This commit is contained in:
@@ -26,7 +26,7 @@ class TradeBloc extends Bloc<TradeEvent, TradeState> {
|
||||
Future<void> _onCloseTrade(CloseTrade event, Emitter<TradeState> emit) async {
|
||||
emit(TradeLoading());
|
||||
try {
|
||||
await repository.closeTrade(event.tradeId);
|
||||
await repository.closeTrade(event.tradeId, dto: event.dto);
|
||||
final trades = await repository.fetchTrades();
|
||||
emit(TradeLoaded(trades));
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/trade_acceptance_dto.dart';
|
||||
import '../models/close_trade_request_dto.dart';
|
||||
|
||||
abstract class TradeEvent extends Equatable {
|
||||
const TradeEvent();
|
||||
@@ -20,11 +21,12 @@ class FetchTrades extends TradeEvent {
|
||||
|
||||
class CloseTrade extends TradeEvent {
|
||||
final String tradeId;
|
||||
final CloseTradeRequestDto? dto;
|
||||
|
||||
const CloseTrade(this.tradeId);
|
||||
const CloseTrade(this.tradeId, {this.dto});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tradeId];
|
||||
List<Object?> get props => [tradeId, dto];
|
||||
}
|
||||
|
||||
class AcceptTradeProposalEvent extends TradeEvent {
|
||||
@@ -35,3 +37,4 @@ class AcceptTradeProposalEvent extends TradeEvent {
|
||||
@override
|
||||
List<Object?> get props => [dto];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
/// Typed DTO for requesting a trade exit/close.
|
||||
class CloseTradeRequestDto {
|
||||
final double userExitPrice;
|
||||
final DateTime? userExitTimestamp;
|
||||
final double exitFee;
|
||||
final String closeReason;
|
||||
|
||||
const CloseTradeRequestDto({required this.userExitPrice});
|
||||
const CloseTradeRequestDto({
|
||||
required this.userExitPrice,
|
||||
this.userExitTimestamp,
|
||||
this.exitFee = 1.0,
|
||||
this.closeReason = 'ManualClosure',
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'userExitPrice': userExitPrice,
|
||||
if (userExitTimestamp != null) 'userExitTimestamp': userExitTimestamp!.toUtc().toIso8601String(),
|
||||
'exitFee': exitFee,
|
||||
'closeReason': closeReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum DriftStatus {
|
||||
onTrack,
|
||||
trailingActive,
|
||||
driftWarning,
|
||||
exitAlert,
|
||||
}
|
||||
|
||||
class TradeHourlyUpdateModel extends Equatable {
|
||||
final String recommendation;
|
||||
final double currentPrice;
|
||||
final double? suggestedStopLoss;
|
||||
final double? suggestedTakeProfit;
|
||||
final double vixValue;
|
||||
final String reasoning;
|
||||
final DateTime timestamp;
|
||||
|
||||
const TradeHourlyUpdateModel({
|
||||
required this.recommendation,
|
||||
required this.currentPrice,
|
||||
this.suggestedStopLoss,
|
||||
this.suggestedTakeProfit,
|
||||
this.vixValue = 0.0,
|
||||
required this.reasoning,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory TradeHourlyUpdateModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
DateTime ts = DateTime.now();
|
||||
final tsStr = (json['timestamp'] ?? json['Timestamp'])?.toString();
|
||||
if (tsStr != null && tsStr.isNotEmpty) {
|
||||
ts = DateTime.tryParse(tsStr) ?? DateTime.now();
|
||||
}
|
||||
|
||||
return TradeHourlyUpdateModel(
|
||||
recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'Hold',
|
||||
currentPrice: parseDbl(json['currentPrice'] ?? json['CurrentPrice']),
|
||||
suggestedStopLoss: json['suggestedStopLoss'] != null ? parseDbl(json['suggestedStopLoss'] ?? json['SuggestedStopLoss']) : null,
|
||||
suggestedTakeProfit: json['suggestedTakeProfit'] != null ? parseDbl(json['suggestedTakeProfit'] ?? json['SuggestedTakeProfit']) : null,
|
||||
vixValue: parseDbl(json['vixValue'] ?? json['VixValue']),
|
||||
reasoning: (json['reasoning'] ?? json['Reasoning'])?.toString() ?? '',
|
||||
timestamp: ts,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [recommendation, currentPrice, suggestedStopLoss, suggestedTakeProfit, reasoning, timestamp];
|
||||
}
|
||||
|
||||
class TradeModel extends Equatable {
|
||||
final String id;
|
||||
final String analysisId;
|
||||
@@ -27,6 +81,9 @@ class TradeModel extends Equatable {
|
||||
final double winRate;
|
||||
final String timeframe;
|
||||
final String instrumentType;
|
||||
final String assetType;
|
||||
final bool hasCfd;
|
||||
final List<String> derivativeProductCategories;
|
||||
final String derivativeIsin;
|
||||
final DateTime? createdAt;
|
||||
|
||||
@@ -41,6 +98,12 @@ class TradeModel extends Equatable {
|
||||
final double exitFee;
|
||||
final double quantity;
|
||||
|
||||
final String closeReason;
|
||||
final DateTime? userExitTimestamp;
|
||||
final bool hasPendingExitAlert;
|
||||
final String pendingExitReason;
|
||||
final List<TradeHourlyUpdateModel> hourlyUpdates;
|
||||
|
||||
const TradeModel({
|
||||
required this.id,
|
||||
this.analysisId = '',
|
||||
@@ -68,6 +131,9 @@ class TradeModel extends Equatable {
|
||||
this.winRate = 50.0,
|
||||
this.timeframe = '1D',
|
||||
this.instrumentType = 'Stock',
|
||||
this.assetType = 'stock',
|
||||
this.hasCfd = false,
|
||||
this.derivativeProductCategories = const [],
|
||||
this.derivativeIsin = '',
|
||||
this.createdAt,
|
||||
this.riskTolerance = 'Moderate',
|
||||
@@ -80,6 +146,11 @@ class TradeModel extends Equatable {
|
||||
this.entryFee = 0.0,
|
||||
this.exitFee = 0.0,
|
||||
this.quantity = 0.0,
|
||||
this.closeReason = '',
|
||||
this.userExitTimestamp,
|
||||
this.hasPendingExitAlert = false,
|
||||
this.pendingExitReason = '',
|
||||
this.hourlyUpdates = const [],
|
||||
});
|
||||
|
||||
bool get isActive => status.toLowerCase() == 'active';
|
||||
@@ -87,6 +158,15 @@ class TradeModel extends Equatable {
|
||||
bool get isRejected => status.toLowerCase() == 'rejected';
|
||||
bool get isProposed => (status.toLowerCase() == 'proposed' || isGlobalProposal) && !isRejected && !isActive && !isClosed;
|
||||
|
||||
DriftStatus get driftStatus {
|
||||
if (hasPendingExitAlert) return DriftStatus.exitAlert;
|
||||
if (hourlyUpdates.any((u) => u.recommendation.toLowerCase().contains('adjustsl') || u.recommendation.toLowerCase().contains('trailing'))) {
|
||||
return DriftStatus.trailingActive;
|
||||
}
|
||||
if (calculatedPnlPct < -3.5) return DriftStatus.driftWarning;
|
||||
return DriftStatus.onTrack;
|
||||
}
|
||||
|
||||
double get effectiveCurrentPrice {
|
||||
if (currentPrice > 0) return currentPrice;
|
||||
if (actualEntryPrice > 0) return actualEntryPrice;
|
||||
@@ -162,6 +242,18 @@ class TradeModel extends Equatable {
|
||||
dt = DateTime.tryParse(createdStr);
|
||||
}
|
||||
|
||||
DateTime? exitDt;
|
||||
final exitStr = (json['userExitTimestamp'] ?? json['UserExitTimestamp'])?.toString();
|
||||
if (exitStr != null && exitStr.isNotEmpty) {
|
||||
exitDt = DateTime.tryParse(exitStr);
|
||||
}
|
||||
|
||||
List<TradeHourlyUpdateModel> updates = [];
|
||||
final rawUpdates = json['hourlyUpdates'] ?? json['HourlyUpdates'];
|
||||
if (rawUpdates is List) {
|
||||
updates = rawUpdates.map((u) => TradeHourlyUpdateModel.fromJson(Map<String, dynamic>.from(u))).toList();
|
||||
}
|
||||
|
||||
return TradeModel(
|
||||
id: idVal,
|
||||
analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '',
|
||||
@@ -189,6 +281,11 @@ class TradeModel extends Equatable {
|
||||
winRate: parseDbl(json['winRate'] ?? json['WinRate']),
|
||||
timeframe: (json['timeframe'] ?? json['Timeframe'])?.toString() ?? '1D',
|
||||
instrumentType: (json['instrumentType'] ?? json['InstrumentType'])?.toString() ?? 'Stock',
|
||||
assetType: (json['assetType'] ?? json['AssetType'])?.toString() ?? 'stock',
|
||||
hasCfd: json['hasCfd'] == true || json['HasCfd'] == true,
|
||||
derivativeProductCategories: (json['derivativeProductCategories'] ?? json['DerivativeProductCategories']) is List
|
||||
? ((json['derivativeProductCategories'] ?? json['DerivativeProductCategories']) as List).map((e) => e.toString()).toList()
|
||||
: const [],
|
||||
derivativeIsin: (json['derivativeIsin'] ?? json['DerivativeIsin'] ?? json['knockoutIsin'] ?? json['KnockoutIsin'])?.toString() ?? '',
|
||||
createdAt: dt,
|
||||
riskTolerance: (json['riskTolerance'] ?? json['RiskTolerance'])?.toString() ?? 'Moderate',
|
||||
@@ -203,6 +300,11 @@ class TradeModel extends Equatable {
|
||||
entryFee: parseDbl(json['entryFee'] ?? json['EntryFee']),
|
||||
exitFee: parseDbl(json['exitFee'] ?? json['ExitFee']),
|
||||
quantity: parseDbl(json['quantity'] ?? json['Quantity']),
|
||||
closeReason: (json['closeReason'] ?? json['CloseReason'])?.toString() ?? '',
|
||||
userExitTimestamp: exitDt,
|
||||
hasPendingExitAlert: json['hasPendingExitAlert'] == true || json['HasPendingExitAlert'] == true,
|
||||
pendingExitReason: (json['pendingExitReason'] ?? json['PendingExitReason'])?.toString() ?? '',
|
||||
hourlyUpdates: updates,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,6 +336,9 @@ class TradeModel extends Equatable {
|
||||
'winRate': winRate,
|
||||
'timeframe': timeframe,
|
||||
'instrumentType': instrumentType,
|
||||
'assetType': assetType,
|
||||
'hasCfd': hasCfd,
|
||||
'derivativeProductCategories': derivativeProductCategories,
|
||||
'derivativeIsin': derivativeIsin,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'riskTolerance': riskTolerance,
|
||||
@@ -246,6 +351,10 @@ class TradeModel extends Equatable {
|
||||
'entryFee': entryFee,
|
||||
'exitFee': exitFee,
|
||||
'quantity': quantity,
|
||||
'closeReason': closeReason,
|
||||
'userExitTimestamp': userExitTimestamp?.toIso8601String(),
|
||||
'hasPendingExitAlert': hasPendingExitAlert,
|
||||
'pendingExitReason': pendingExitReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,5 +372,8 @@ class TradeModel extends Equatable {
|
||||
currentPrice,
|
||||
pnlAbsolute,
|
||||
pnlPercent,
|
||||
hasPendingExitAlert,
|
||||
hourlyUpdates,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
||||
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
|
||||
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
|
||||
import 'package:finlytic_app/features/trades/models/derivative_item_model.dart';
|
||||
|
||||
class TradeRepository {
|
||||
final ApiClient apiClient;
|
||||
@@ -29,6 +30,42 @@ class TradeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<DerivativeItemModel>> fetchDerivatives(
|
||||
String isin, {
|
||||
String optionType = 'long',
|
||||
double? targetLeverage,
|
||||
double? minLeverage,
|
||||
double? maxLeverage,
|
||||
String? search,
|
||||
String? after,
|
||||
int? page,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'optionType': optionType,
|
||||
if (targetLeverage != null && targetLeverage > 0) 'targetLeverage': targetLeverage,
|
||||
if (minLeverage != null) 'minLeverage': minLeverage,
|
||||
if (maxLeverage != null) 'maxLeverage': maxLeverage,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
if (after != null && after.isNotEmpty) 'after': after,
|
||||
if (page != null) 'page': page,
|
||||
if (forceRefresh) 'forceRefresh': 'true',
|
||||
'_t': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
final response = await apiClient.get('/api/v1/assets/$isin/derivatives', queryParameters: queryParams);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => DerivativeItemModel.fromJson(json)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
throw Exception('Derivate konnten nicht geladen werden: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> acceptTrade(TradeAcceptanceDto dto) async {
|
||||
final response = await apiClient.post('/api/v1/user/trades/accept', data: dto.toJson());
|
||||
if (response.statusCode != 200) {
|
||||
@@ -50,3 +87,4 @@ class TradeRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,16 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/signalr_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../auth/bloc/auth_bloc.dart';
|
||||
import '../bloc/trade_bloc.dart';
|
||||
import '../bloc/trade_event.dart';
|
||||
import '../bloc/trade_state.dart';
|
||||
import '../models/trade_model.dart';
|
||||
import '../models/trade_acceptance_dto.dart';
|
||||
import '../models/close_trade_request_dto.dart';
|
||||
import '../repositories/trade_repository.dart';
|
||||
import '../widgets/trade_card.dart';
|
||||
import '../widgets/proposed_auto_trades_card.dart';
|
||||
import '../widgets/trade_acceptance_dialog.dart';
|
||||
import '../widgets/trade_execution_dialog.dart';
|
||||
import '../widgets/trade_execution_cockpit.dart';
|
||||
import '../widgets/trade_closing_cockpit.dart';
|
||||
import '../widgets/trade_performance_bar.dart';
|
||||
|
||||
class TradesFeedScreen extends StatelessWidget {
|
||||
@@ -55,28 +54,45 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleAcceptProposal(BuildContext context, TradeModel trade) async {
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
final currentUserId = (authState is Authenticated) ? authState.user.userId : 'default_user';
|
||||
void _handleAcceptProposal(BuildContext context, TradeModel trade, {bool isActive = false}) {
|
||||
final tradeBloc = context.read<TradeBloc>();
|
||||
|
||||
final result = await showDialog<TradeAcceptanceDto>(
|
||||
context: context,
|
||||
builder: (ctx) => TradeAcceptanceDialog(
|
||||
trade: trade,
|
||||
theme: AppTheme.darkClassic,
|
||||
userId: currentUserId,
|
||||
),
|
||||
TradeExecutionCockpit.show(
|
||||
context,
|
||||
trade: trade,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
|
||||
isActive: isActive,
|
||||
onAccept: (dto) {
|
||||
tradeBloc.add(AcceptTradeProposalEvent(dto));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isActive ? 'Einstellungen für ${trade.symbol} gespeichert!' : 'Trade für ${trade.symbol} eröffnet!'),
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (result != null && mounted) {
|
||||
context.read<TradeBloc>().add(AcceptTradeProposalEvent(result));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Trade wird in dein Portfolio übernommen...'),
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
),
|
||||
);
|
||||
}
|
||||
void _handleCloseTrade(BuildContext context, TradeModel trade) {
|
||||
final tradeBloc = context.read<TradeBloc>();
|
||||
|
||||
TradeClosingCockpit.show(
|
||||
context,
|
||||
trade: trade,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
|
||||
onClose: (CloseTradeRequestDto dto) {
|
||||
tradeBloc.add(CloseTrade(trade.id, dto: dto));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Position ${trade.symbol} geschlossen! Realisierter Verkaufskurs: €${dto.userExitPrice.toStringAsFixed(2)}'),
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -102,12 +118,12 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Live Portfolio & Trading',
|
||||
'Live Portfolio & Trades',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'KI-Erkennungen, Vorschläge & Aktive Positionen',
|
||||
'KI-Guardian Überwachung, Drift-Radar & Order-Cockpit',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
@@ -211,11 +227,11 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_filterChip('Alle', allTrades.length),
|
||||
_filterChip('Offen', activeTrades.length),
|
||||
_filterChip('Vorschläge', proposals.length),
|
||||
_filterChip('Abgelehnt', rejectedTrades.length),
|
||||
_filterChip('Geschlossen', closedTrades.length),
|
||||
_filterChip('Abgelehnt', rejectedTrades.length),
|
||||
_filterChip('Alle', allTrades.length),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -228,7 +244,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
children: [
|
||||
Icon(Icons.inbox, size: 40, color: AppTheme.textMuted),
|
||||
const SizedBox(height: 8),
|
||||
Text('Keine Trades in der Kategorie "$_selectedFilter" gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||
Text('Keine Trades in der Kategorie "$_selectedFilter" vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -243,13 +259,8 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
return TradeCard(
|
||||
trade: trade,
|
||||
onAccept: () => _handleAcceptProposal(context, trade),
|
||||
onSettings: () => _showTradeSettingsDialog(context, trade),
|
||||
onClose: () {
|
||||
context.read<TradeBloc>().add(CloseTrade(trade.id));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Position wird geschlossen...')),
|
||||
);
|
||||
},
|
||||
onSettings: () => _handleAcceptProposal(context, trade, isActive: true),
|
||||
onClose: () => _handleCloseTrade(context, trade),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -312,24 +323,5 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTradeSettingsDialog(BuildContext context, TradeModel trade) {
|
||||
final tradeBloc = context.read<TradeBloc>();
|
||||
|
||||
TradeExecutionDialog.show(
|
||||
context,
|
||||
trade: trade,
|
||||
defaultSymbol: trade.symbol,
|
||||
isActive: true,
|
||||
onAccept: (dto) {
|
||||
tradeBloc.add(AcceptTradeProposalEvent(dto));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Einstellungen für ${trade.symbol} gespeichert.'),
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
|
||||
super.initState();
|
||||
_entryPriceCtrl = TextEditingController(text: widget.trade.entryPrice.toStringAsFixed(2));
|
||||
_positionSizeCtrl = TextEditingController(text: '1000');
|
||||
_leverageCtrl = TextEditingController(text: (widget.trade.maxLeverage > 0 ? widget.trade.maxLeverage : 1).toStringAsFixed(0));
|
||||
final lev = widget.trade.maxLeverage > 0 ? widget.trade.maxLeverage : 1.0;
|
||||
_leverageCtrl = TextEditingController(text: lev == lev.roundToDouble() ? lev.toInt().toString() : lev.toStringAsFixed(2));
|
||||
_stopLossCtrl = TextEditingController(text: widget.trade.stopLoss.toStringAsFixed(2));
|
||||
_takeProfitCtrl = TextEditingController(text: widget.trade.takeProfit.toStringAsFixed(2));
|
||||
_notesCtrl = TextEditingController();
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../models/trade_model.dart';
|
||||
|
||||
class TradeCalculationCard extends StatelessWidget {
|
||||
final TradeModel trade;
|
||||
final bool initiallyExpanded;
|
||||
final bool isCollapsible;
|
||||
|
||||
const TradeCalculationCard({
|
||||
super.key,
|
||||
required this.trade,
|
||||
this.initiallyExpanded = true,
|
||||
this.isCollapsible = false,
|
||||
});
|
||||
|
||||
String _formatLeverage(double lev) {
|
||||
if (lev <= 0) return '1x';
|
||||
if (lev == lev.roundToDouble()) {
|
||||
return '${lev.toInt()}x';
|
||||
}
|
||||
var s = lev.toStringAsFixed(2);
|
||||
if (s.endsWith('0')) {
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
return '${s.replaceAll('.', ',')}x';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entry = trade.actualEntryPrice > 0
|
||||
? trade.actualEntryPrice
|
||||
: (trade.entryPrice > 0 ? trade.entryPrice : 1.0);
|
||||
final posSize = trade.positionSize > 0 ? trade.positionSize : 1000.0;
|
||||
final lev = trade.leverageUsed > 0 ? trade.leverageUsed : 1.0;
|
||||
final isShort = trade.signalType.toUpperCase() == 'SELL' ||
|
||||
trade.signalType.toUpperCase() == 'SHORT';
|
||||
final totalFees = trade.entryFee + (trade.exitFee > 0 ? trade.exitFee : 1.0);
|
||||
|
||||
final quantity = entry > 0 ? (posSize / entry) : 0.0;
|
||||
|
||||
// SL Risk
|
||||
final sl = trade.stopLoss;
|
||||
final movePctSL = entry > 0 && sl > 0
|
||||
? (isShort ? ((sl - entry) / entry) : ((entry - sl) / entry))
|
||||
: 0.0;
|
||||
final rawLoss = (movePctSL * posSize * lev).abs();
|
||||
final isDerivative = trade.instrumentType.toLowerCase().contains('knock') ||
|
||||
trade.instrumentType.toLowerCase().contains('option') ||
|
||||
trade.instrumentType.toLowerCase().contains('factor') ||
|
||||
trade.instrumentType.toLowerCase().contains('turbo');
|
||||
final cappedLoss = isDerivative ? rawLoss.clamp(0.0, posSize) : rawLoss;
|
||||
final riskAmountAbs = cappedLoss + totalFees;
|
||||
|
||||
// TP Reward
|
||||
final tp = trade.takeProfit;
|
||||
final movePctTP = entry > 0 && tp > 0
|
||||
? (isShort ? ((entry - tp) / entry) : ((tp - entry) / entry))
|
||||
: 0.0;
|
||||
final rawProfit = (movePctTP * posSize * lev);
|
||||
final profitAfterFees = rawProfit - totalFees;
|
||||
final rewardAmountAbs = profitAfterFees > 0 ? profitAfterFees : 0.0;
|
||||
|
||||
// CRV
|
||||
final crv = (riskAmountAbs > 0 && rewardAmountAbs > 0)
|
||||
? (rewardAmountAbs / riskAmountAbs)
|
||||
: 0.0;
|
||||
|
||||
// Multi-Targets
|
||||
final targets = trade.takeProfitTargets.isNotEmpty
|
||||
? trade.takeProfitTargets
|
||||
: (trade.takeProfit > 0 ? [trade.takeProfit] : <double>[]);
|
||||
|
||||
final content = Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_statTile(
|
||||
'Stückzahl (Basiswert)',
|
||||
'${quantity.toStringAsFixed(2)} Stk.',
|
||||
Colors.white,
|
||||
),
|
||||
_statTile(
|
||||
'Max. Verlust (SL)',
|
||||
'-€${riskAmountAbs.toStringAsFixed(2)}',
|
||||
AppTheme.accentRed,
|
||||
),
|
||||
_statTile(
|
||||
'Gewinn-Potenzial (TP)',
|
||||
'+€${rewardAmountAbs.toStringAsFixed(2)}',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
_statTile(
|
||||
'Chance-Risiko (CRV)',
|
||||
crv > 0 ? '1 : ${crv.toStringAsFixed(2)}' : '-',
|
||||
AppTheme.accentCyan,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Pauschalgebühren: €${totalFees.toStringAsFixed(2)} (€${trade.entryFee.toStringAsFixed(2)} Kauf + €${(trade.exitFee > 0 ? trade.exitFee : 1.0).toStringAsFixed(2)} Verkauf)',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
if (lev > 1.0)
|
||||
Text(
|
||||
'Effektiver Hebel: ${_formatLeverage(lev)}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentCyan,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (targets.length > 1) ...[
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'🎯 MEHRSTUFIGE GEWINN-KALKULATION',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryEmerald,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${targets.length} Ziele',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...targets.asMap().entries.map((entryItem) {
|
||||
final idx = entryItem.key;
|
||||
final targetPrice = entryItem.value;
|
||||
final isCurrent = (tp - targetPrice).abs() < 0.001;
|
||||
|
||||
final targetMovePct = entry > 0
|
||||
? (isShort
|
||||
? ((entry - targetPrice) / entry)
|
||||
: ((targetPrice - entry) / entry))
|
||||
: 0.0;
|
||||
final rawTargetProfit = targetMovePct * posSize * lev;
|
||||
final netTargetProfit = rawTargetProfit - totalFees;
|
||||
final cappedNet = netTargetProfit > 0 ? netTargetProfit : 0.0;
|
||||
final retPct = posSize > 0 ? (cappedNet / posSize * 100) : 0.0;
|
||||
final targetCrv = (riskAmountAbs > 0 && cappedNet > 0)
|
||||
? (cappedNet / riskAmountAbs)
|
||||
: 0.0;
|
||||
final baseMove = (targetMovePct * 100).abs();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent
|
||||
? AppTheme.primaryEmerald.withValues(alpha: 0.14)
|
||||
: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isCurrent
|
||||
? AppTheme.primaryEmerald.withValues(alpha: 0.7)
|
||||
: Colors.white.withValues(alpha: 0.07),
|
||||
width: isCurrent ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent
|
||||
? AppTheme.primaryEmerald
|
||||
: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'TP${idx + 1}',
|
||||
style: TextStyle(
|
||||
color: isCurrent ? Colors.black : Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'€${targetPrice.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'(+${baseMove.toStringAsFixed(1)}% Basiswert)',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textMuted, fontSize: 10.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'+€${cappedNet.toStringAsFixed(2)} (+${retPct.toStringAsFixed(1)}%)',
|
||||
style: TextStyle(
|
||||
color: isCurrent
|
||||
? AppTheme.primaryEmerald
|
||||
: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
if (targetCrv > 0)
|
||||
Text(
|
||||
'CRV 1 : ${targetCrv.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: isCurrent
|
||||
? AppTheme.primaryEmerald
|
||||
: AppTheme.accentCyan,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (!isCollapsible) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'3. LIVE-KALKULATION (AUTOMATISCH)',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
content,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Theme(
|
||||
data: ThemeData(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
iconColor: AppTheme.primaryEmerald,
|
||||
collapsedIconColor: Colors.white70,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.calculate_outlined, color: AppTheme.primaryEmerald, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Live-Kalkulation & Gewinn-Potenzial',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
content,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statTile(String label, String value, Color col) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
color: col, fontWeight: FontWeight.bold, fontSize: 12.5)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/status_badge.dart';
|
||||
import '../../favorites/cubit/favorites_cubit.dart';
|
||||
import '../../favorites/models/favorite_asset_model.dart';
|
||||
import '../models/trade_model.dart';
|
||||
import 'trade_calculation_card.dart';
|
||||
import 'trade_detail_modal.dart';
|
||||
|
||||
class TradeCard extends StatelessWidget {
|
||||
@@ -23,7 +25,7 @@ class TradeCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBuy = trade.signalType == 'BUY';
|
||||
final isBuy = trade.signalType == 'BUY' || trade.signalType == 'LONG';
|
||||
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
final isProposed = trade.isProposed;
|
||||
final isActive = trade.isActive;
|
||||
@@ -47,224 +49,422 @@ class TradeCard extends StatelessWidget {
|
||||
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
final currPrice = livePrice > 0 ? livePrice : trade.effectiveCurrentPrice;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => TradeDetailModal.show(
|
||||
context,
|
||||
trade: trade,
|
||||
onAccept: onAccept,
|
||||
onClose: onClose,
|
||||
),
|
||||
child: GlassContainer(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Row: Signal, Symbol, Status & Live PnL
|
||||
Row(
|
||||
return GlassContainer(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
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.signalType,
|
||||
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN'
|
||||
? trade.companyName
|
||||
: (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' ? trade.symbol : (trade.isin.isNotEmpty ? trade.isin : 'Aktie')),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||
),
|
||||
if (trade.isin.isNotEmpty || (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' && trade.symbol != trade.companyName))
|
||||
Text(
|
||||
trade.isin.isNotEmpty ? trade.isin : trade.symbol,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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.toStringAsFixed(2)} €',
|
||||
style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%',
|
||||
style: TextStyle(color: pnlColor, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (trade.isRejected)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
'ABGELEHNT',
|
||||
style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 10),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.amber.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: const Text(
|
||||
'VORSCHLAG',
|
||||
style: TextStyle(color: Colors.amber, fontWeight: FontWeight.bold, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Price Metrics Grid with Live Kurs
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_priceItem(
|
||||
isActive || isClosed ? 'Ausführung' : 'Ziel-Einstieg',
|
||||
trade.actualEntryPrice > 0
|
||||
? '${trade.actualEntryPrice.toStringAsFixed(2)} €'
|
||||
: (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)} €' : '-'),
|
||||
Colors.white
|
||||
),
|
||||
_priceItem('Live-Kurs', '${currPrice.toStringAsFixed(2)} €', AppTheme.accentCyan),
|
||||
_priceItem('Stop-Loss', '${trade.stopLoss.toStringAsFixed(2)} €', AppTheme.accentRed),
|
||||
_priceItem('Take-Profit', '${trade.takeProfit.toStringAsFixed(2)} €', AppTheme.primaryEmerald),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (trade.reasoning.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
trade.reasoning,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Footer Action Row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${trade.instrumentType.isNotEmpty ? trade.instrumentType : "Stock"}${trade.derivativeIsin.isNotEmpty ? " (${trade.derivativeIsin})" : ""} • ${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(0)}x Hebel" : ""}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
|
||||
// Header Row: Signal, Symbol, Drift-Radar & Live PnL
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => TradeDetailModal.show(
|
||||
context,
|
||||
trade: trade,
|
||||
onAccept: onAccept,
|
||||
onClose: onClose,
|
||||
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.signalType, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan),
|
||||
tooltip: 'KI-Begründung & 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.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN'
|
||||
? trade.companyName
|
||||
: (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' ? trade.symbol : (trade.isin.isNotEmpty ? trade.isin : 'Position')),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (trade.instrumentType.isNotEmpty) ...[
|
||||
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, style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${trade.symbol.isNotEmpty ? trade.symbol : ""} ${trade.isin.isNotEmpty ? "• " + trade.isin : ""}',
|
||||
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: 'ABGELEHNT', color: AppTheme.accentRed),
|
||||
] else ...[
|
||||
StatusBadge(label: 'VORSCHLAG', color: Colors.amber),
|
||||
],
|
||||
if (isActive && onClose != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onClose,
|
||||
icon: Icon(Icons.close, size: 14, color: AppTheme.accentRed),
|
||||
label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
],
|
||||
),
|
||||
|
||||
// Active Drift Radar / Trailing Alert Indicator
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildDriftRadarBar(trade),
|
||||
],
|
||||
|
||||
// PENDING EXIT ALERT BANNER (Zero Auto-Close notification)
|
||||
if (isActive && trade.hasPendingExitAlert) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'KI-Guardian Ratschlag: Position schließen!',
|
||||
style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
if (trade.pendingExitReason.isNotEmpty)
|
||||
Text(
|
||||
trade.pendingExitReason,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onClose != null)
|
||||
ElevatedButton(
|
||||
onPressed: onClose,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentRed,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
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(
|
||||
isActive || isClosed ? 'Einstieg' : 'Ziel-Einstieg',
|
||||
trade.actualEntryPrice > 0
|
||||
? '€${trade.actualEntryPrice.toStringAsFixed(2)}'
|
||||
: (trade.entryPrice > 0 ? '€${trade.entryPrice.toStringAsFixed(2)}' : '-'),
|
||||
Colors.white,
|
||||
),
|
||||
_priceItem('Live-Kurs', '€${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan),
|
||||
_priceItem(
|
||||
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
|
||||
'€${trade.stopLoss.toStringAsFixed(2)}',
|
||||
AppTheme.accentRed,
|
||||
),
|
||||
_priceItem(
|
||||
trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit',
|
||||
'€${trade.takeProfit.toStringAsFixed(2)}',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
],
|
||||
if (isActive && onSettings != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
onPressed: onSettings,
|
||||
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
|
||||
tooltip: 'Einstellungen',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.glassSurface,
|
||||
),
|
||||
),
|
||||
|
||||
if (trade.takeProfitTargets.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: trade.takeProfitTargets.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final tpVal = entry.value;
|
||||
final isCurrent = (trade.takeProfit - tpVal).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${idx + 1}: €${tpVal.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
|
||||
fontSize: 10.5,
|
||||
fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (trade.reasoning.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
trade.reasoning,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
// KI-Timeline Expansion if updates exist
|
||||
if (trade.hourlyUpdates.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(
|
||||
'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)',
|
||||
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
children: trade.hourlyUpdates.reversed.take(4).map((u) {
|
||||
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(
|
||||
'${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (u.recommendation.toLowerCase().contains('close')
|
||||
? AppTheme.accentRed
|
||||
: (u.recommendation.toLowerCase().contains('adjust') ? Colors.blue : AppTheme.primaryEmerald))
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(u.recommendation, style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
u.reasoning.isNotEmpty ? u.reasoning : 'Kurs: €${u.currentPrice.toStringAsFixed(2)} | VIX: ${u.vixValue.toStringAsFixed(1)}',
|
||||
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.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(1)}x Hebel" : ""}',
|
||||
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: 'KI-Begründung & 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.exitAlert:
|
||||
col = AppTheme.accentRed;
|
||||
label = 'Drift-Radar: Ausstieg empfohlen';
|
||||
icon = Icons.warning_rounded;
|
||||
break;
|
||||
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 • KI überwacht stündlich';
|
||||
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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../models/trade_model.dart';
|
||||
import 'trade_calculation_card.dart';
|
||||
|
||||
class TradeDetailContent extends StatelessWidget {
|
||||
final TradeModel trade;
|
||||
@@ -18,6 +19,45 @@ class TradeDetailContent extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Drift Radar Status
|
||||
if (trade.isActive) ...[
|
||||
_buildDriftRadarCard(trade),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Exit Alert if pending
|
||||
if (trade.isActive && trade.hasPendingExitAlert) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Ausstiegs-Empfehlung der KI!', style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
trade.pendingExitReason.isNotEmpty ? trade.pendingExitReason : 'Die Indikatoren raten zum Verlassen der Position zur Gewinnsicherung / Risikominimierung.',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Metrics Grid
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -31,16 +71,69 @@ class TradeDetailContent extends StatelessWidget {
|
||||
_metricItem(
|
||||
trade.isActive || trade.isClosed ? 'Ausführung' : 'Ziel-Einstieg',
|
||||
trade.actualEntryPrice > 0
|
||||
? '${trade.actualEntryPrice.toStringAsFixed(2)} €'
|
||||
: (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)} €' : '-'),
|
||||
? '€${trade.actualEntryPrice.toStringAsFixed(2)}'
|
||||
: (trade.entryPrice > 0 ? '€${trade.entryPrice.toStringAsFixed(2)}' : '-'),
|
||||
Colors.white,
|
||||
),
|
||||
_metricItem('Live-Kurs', '${currPrice.toStringAsFixed(2)} €', AppTheme.accentCyan),
|
||||
_metricItem('Stop-Loss', '${trade.stopLoss.toStringAsFixed(2)} €', AppTheme.accentRed),
|
||||
_metricItem('Take-Profit', '${trade.takeProfit.toStringAsFixed(2)} €', AppTheme.primaryEmerald),
|
||||
_metricItem('Live-Kurs', '€${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan),
|
||||
_metricItem(
|
||||
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
|
||||
'€${trade.stopLoss.toStringAsFixed(2)}',
|
||||
AppTheme.accentRed,
|
||||
),
|
||||
_metricItem(
|
||||
trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit',
|
||||
'€${trade.takeProfit.toStringAsFixed(2)}',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trade.takeProfitTargets.length > 1) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Alle Gewinn-Ziele: ',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: trade.takeProfitTargets.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final tpVal = entry.value;
|
||||
final isCurrent = (trade.takeProfit - tpVal).abs() < 0.01;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
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${idx + 1}: €${tpVal.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
|
||||
fontSize: 11,
|
||||
fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (trade.isActive || trade.isClosed) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
@@ -55,7 +148,7 @@ class TradeDetailContent extends StatelessWidget {
|
||||
children: [
|
||||
const Text('Aktueller PnL:', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Text(
|
||||
'${isPnlPos ? '+' : ''}${pnlAbs.toStringAsFixed(2)} € (${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%)',
|
||||
'${isPnlPos ? '+' : ''}€${pnlAbs.abs().toStringAsFixed(2)} (${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%)',
|
||||
style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
],
|
||||
@@ -63,66 +156,12 @@ class TradeDetailContent extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
if (trade.reasoning.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.auto_awesome, 'KI-Gesamteinschätzung & Begründung', AppTheme.primaryEmerald),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Text(trade.reasoning, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
if (trade.technicalRationale.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.show_chart, 'Technische Analyse & Indikatoren', AppTheme.accentCyan),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Text(trade.technicalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
if (trade.fundamentalRationale.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.account_balance, 'Fundamentale Bewertung', Colors.purpleAccent),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Text(trade.fundamentalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
if (trade.riskWarning.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.warning_amber_rounded, 'Risikohinweis & Marktumfeld', AppTheme.accentRed),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(trade.riskWarning, style: TextStyle(color: AppTheme.accentRed, fontSize: 12, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
|
||||
// 3. LIVE-KALKULATION (AUTOMATISCH) & MEHRSTUFIGE TP-ZIELE
|
||||
TradeCalculationCard(trade: trade),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Trade Parameters & Instrument
|
||||
_sectionTitle(Icons.tune, 'Trade-Parameter & Instrument', Colors.white70),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -137,15 +176,222 @@ class TradeDetailContent extends StatelessWidget {
|
||||
_paramRow('Instrument Typ:', trade.instrumentType.isNotEmpty ? trade.instrumentType : 'Stock'),
|
||||
if (trade.derivativeIsin.isNotEmpty) _paramRow('Derivat / Hebel ISIN:', trade.derivativeIsin),
|
||||
_paramRow('Zeithorizont:', trade.timeframe.isNotEmpty ? trade.timeframe : '1D'),
|
||||
if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(0)}x'),
|
||||
if (trade.positionSize > 0) _paramRow('Positionsgröße:', '${trade.positionSize.toStringAsFixed(2)} €'),
|
||||
if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(1)}x'),
|
||||
if (trade.positionSize > 0) _paramRow('Positionsgröße:', '€${trade.positionSize.toStringAsFixed(2)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// AUFKLAPPBARE KARTE: KI-Analysen, Bewertungen & Begründungen
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Theme(
|
||||
data: ThemeData(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: false,
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
iconColor: AppTheme.accentCyan,
|
||||
collapsedIconColor: Colors.white70,
|
||||
leading: Icon(Icons.auto_awesome, color: AppTheme.primaryEmerald, size: 20),
|
||||
title: const Text(
|
||||
'KI-Analysen, Bewertungen & Begründungen',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Technische & fundamentale Begründung, Risikowarnung & Guardian-Protokoll',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
children: [
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
|
||||
// Hourly Updates Timeline
|
||||
if (trade.hourlyUpdates.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.history_toggle_off, 'KI-Guardian Überwachungsprotokoll (${trade.hourlyUpdates.length} Checks)', AppTheme.accentCyan),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||
),
|
||||
child: Column(
|
||||
children: trade.hourlyUpdates.reversed.map((u) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${u.timestamp.day.toString().padLeft(2, '0')}.${u.timestamp.month.toString().padLeft(2, '0')} ${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (u.recommendation.toLowerCase().contains('close')
|
||||
? AppTheme.accentRed
|
||||
: (u.recommendation.toLowerCase().contains('adjust') ? Colors.blue : AppTheme.primaryEmerald))
|
||||
.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(u.recommendation, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
u.reasoning.isNotEmpty ? u.reasoning : 'Stündliche Überprüfung durchgeführt.',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'Kurs: €${u.currentPrice.toStringAsFixed(2)}${u.suggestedStopLoss != null ? " • Neuer SL: €${u.suggestedStopLoss!.toStringAsFixed(2)}" : ""} • VIX: ${u.vixValue.toStringAsFixed(1)}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (trade.reasoning.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.auto_awesome, 'KI-Gesamteinschätzung & Begründung', AppTheme.primaryEmerald),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Text(trade.reasoning, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (trade.technicalRationale.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.show_chart, 'Technische Analyse & Indikatoren', AppTheme.accentCyan),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Text(trade.technicalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 12.5, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (trade.fundamentalRationale.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.account_balance, 'Fundamentale Bewertung', Colors.purpleAccent),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Text(trade.fundamentalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 12.5, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (trade.riskWarning.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.warning_amber_rounded, 'Risikohinweis & Marktumfeld', AppTheme.accentRed),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(trade.riskWarning, style: TextStyle(color: AppTheme.accentRed, fontSize: 12, height: 1.4)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDriftRadarCard(TradeModel t) {
|
||||
Color col;
|
||||
String title;
|
||||
String desc;
|
||||
|
||||
switch (t.driftStatus) {
|
||||
case DriftStatus.exitAlert:
|
||||
col = AppTheme.accentRed;
|
||||
title = 'Ausstiegssignal aktiv';
|
||||
desc = 'Die Marktbedingungen oder Stop-Limits deuten auf einen Ausstieg hin.';
|
||||
break;
|
||||
case DriftStatus.trailingActive:
|
||||
col = AppTheme.accentCyan;
|
||||
title = 'Trailing Stop aktiv nachgezogen';
|
||||
desc = 'Die KI hat den Stop-Loss zur Absicherung von Gewinnen nachgezogen.';
|
||||
break;
|
||||
case DriftStatus.driftWarning:
|
||||
col = Colors.orangeAccent;
|
||||
title = 'Leichte Drift / Kursabweichung';
|
||||
desc = 'Der Kurs bewegt sich leicht entgegen der primären Prognose.';
|
||||
break;
|
||||
case DriftStatus.onTrack:
|
||||
col = AppTheme.primaryEmerald;
|
||||
title = 'Auf Kurs • Prognose intakt';
|
||||
desc = 'Die Entwicklung entspricht der statistischen KI-Prognose.';
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: col.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: col.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.radar, color: col, size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Drift-Radar: $title', style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Text(desc, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(IconData icon, String title, Color color) {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -179,3 +425,4 @@ class TradeDetailContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ import 'trade_execution_ai_plan_card.dart';
|
||||
class TradeExecutionDialog {
|
||||
static const double _defaultPositionSize = 1000.0;
|
||||
static const double _defaultLeverage = 1.0;
|
||||
static const List<String> _allowedInstruments = ['Stock', 'KnockOut', 'Option', 'CFD', 'Crypto'];
|
||||
|
||||
static String _normalizeInstrumentType(String raw) {
|
||||
final clean = raw.toLowerCase().trim();
|
||||
@@ -167,25 +166,56 @@ class TradeExecutionDialog {
|
||||
return StatefulBuilder(
|
||||
builder: (stfContext, setModalState) {
|
||||
final isKnockout = selectedInstrumentType.toLowerCase().contains('knock') ||
|
||||
selectedInstrumentType.toLowerCase().contains('zertifikat') ||
|
||||
selectedInstrumentType.toLowerCase().contains('option') ||
|
||||
selectedInstrumentType.toLowerCase().contains('cfd');
|
||||
selectedInstrumentType.toLowerCase().contains('factor') ||
|
||||
selectedInstrumentType.toLowerCase().contains('derivat');
|
||||
|
||||
final safeInstrumentValue = _allowedInstruments.contains(selectedInstrumentType) ? selectedInstrumentType : 'KnockOut';
|
||||
final assetType = trade.assetType.toLowerCase();
|
||||
final categories = trade.derivativeProductCategories;
|
||||
final hasCfd = trade.hasCfd;
|
||||
|
||||
final availableOptions = <MapEntry<String, String>>[];
|
||||
if (assetType == 'crypto') {
|
||||
availableOptions.add(const MapEntry('Crypto', 'Krypto'));
|
||||
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'Krypto CFD'));
|
||||
} else if (assetType == 'etf') {
|
||||
availableOptions.add(const MapEntry('Stock', 'ETF (Direktinvestment)'));
|
||||
if (categories.isEmpty || categories.contains('knockOutProduct')) {
|
||||
availableOptions.add(const MapEntry('KnockOut', 'Knock-Out Zertifikat'));
|
||||
}
|
||||
if (categories.contains('vanillaWarrant')) {
|
||||
availableOptions.add(const MapEntry('Option', 'Optionsschein'));
|
||||
}
|
||||
if (categories.contains('factorCertificate')) {
|
||||
availableOptions.add(const MapEntry('Factor', 'Faktor-Zertifikat'));
|
||||
}
|
||||
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'CFD (Hebel-Derivat)'));
|
||||
} else {
|
||||
availableOptions.add(const MapEntry('Stock', 'Aktie (Direktinvestment)'));
|
||||
if (categories.isEmpty || categories.contains('knockOutProduct')) {
|
||||
availableOptions.add(const MapEntry('KnockOut', 'Knock-Out Zertifikat'));
|
||||
}
|
||||
if (categories.contains('vanillaWarrant')) {
|
||||
availableOptions.add(const MapEntry('Option', 'Optionsschein'));
|
||||
}
|
||||
if (categories.contains('factorCertificate')) {
|
||||
availableOptions.add(const MapEntry('Factor', 'Faktor-Zertifikat'));
|
||||
}
|
||||
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'CFD (Hebel-Derivat)'));
|
||||
}
|
||||
|
||||
final safeInstrumentValue = availableOptions.any((o) => o.key == selectedInstrumentType)
|
||||
? selectedInstrumentType
|
||||
: (availableOptions.isNotEmpty ? availableOptions.first.key : 'Stock');
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppTheme.glassBorder)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(isActive ? Icons.tune : Icons.edit_note_outlined, color: AppTheme.primaryEmerald, size: 22),
|
||||
Icon(Icons.flash_on, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isActive ? 'Einstellungen für Trade #${trade.id}' : 'Trade-Ausführung & Parameter',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Text(isActive ? 'Aktiven Trade anpassen' : 'Trade-Vorschlag ausführen', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
@@ -205,13 +235,9 @@ class TradeExecutionDialog {
|
||||
initialValue: safeInstrumentValue,
|
||||
dropdownColor: AppTheme.cardSurface,
|
||||
decoration: const InputDecoration(labelText: 'Finanzinstrument Typ', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'Stock', child: Text('Aktie / ETF (Direktinvestment)', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||
DropdownMenuItem(value: 'KnockOut', child: Text('Knock-Out Zertifikat', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||
DropdownMenuItem(value: 'Option', child: Text('Optionsschein / Derivat', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||
DropdownMenuItem(value: 'CFD', child: Text('CFD (Hebel-Derivat)', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||
DropdownMenuItem(value: 'Crypto', child: Text('Krypto', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||
],
|
||||
items: availableOptions.map((opt) {
|
||||
return DropdownMenuItem(value: opt.key, child: Text(opt.value, style: const TextStyle(color: Colors.white, fontSize: 13)));
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setModalState(() => selectedInstrumentType = val);
|
||||
},
|
||||
@@ -318,6 +344,48 @@ class TradeExecutionDialog {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trade.takeProfitTargets.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: trade.takeProfitTargets.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final targetPrice = entry.value;
|
||||
return ActionChip(
|
||||
label: Text('TP${idx + 1}: €${targetPrice.toStringAsFixed(2)}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
tpController.text = targetPrice.toStringAsFixed(2);
|
||||
},
|
||||
backgroundColor: Colors.white10,
|
||||
side: BorderSide(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.amber.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.gavel_outlined, size: 14, color: Colors.amber.withValues(alpha: 0.85)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Rechtlicher Hinweis: Keine Anlageberatung. Sämtliche Angaben dienen ausschließlich Informationszwecken. Hebelprodukte bergen ein hohes Verlustrisiko bis hin zum Totalverlust.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 10, height: 1.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user