feat(app): clean architecture with typed repositories, DTO models and calendar event logos

This commit is contained in:
2026-08-15 01:03:22 +02:00
parent f08fecde23
commit 15f8f7896e
34 changed files with 1435 additions and 1420 deletions
@@ -1,18 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/core/theme/app_theme.dart';
import 'package:finlytic_app/core/widgets/status_badge.dart';
import 'package:finlytic_app/core/network/api_client.dart';
import '../../../../features/trades/models/trade_model.dart';
import '../../../../features/trades/models/trade_acceptance_dto.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/network/api_client.dart';
import '../../asset_detail/repositories/asset_repository.dart';
import '../models/trade_model.dart';
import '../models/trade_acceptance_dto.dart';
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'];
/// Normalisiert beliebige Freitexte/Bezeichnungen auf die erlaubten Dropdown-Werte
static String _normalizeInstrumentType(String raw) {
final clean = raw.toLowerCase().trim();
if (clean.contains('knock') || clean.contains('zertifikat') || clean.contains('turbo')) {
@@ -30,21 +29,20 @@ class TradeExecutionDialog {
if (clean.contains('stock') || clean.contains('aktie') || clean.contains('etf')) {
return 'Stock';
}
return 'KnockOut'; // Fallback
return 'KnockOut';
}
static void show(
BuildContext context, {
required TradeModel trade,
required String defaultSymbol,
bool isActive = false,
required Function(TradeAcceptanceDto dto) onAccept,
Function(String tradeId)? onReject,
}) {
BuildContext context, {
required TradeModel trade,
required String defaultSymbol,
bool isActive = false,
required Function(TradeAcceptanceDto dto) onAccept,
Function(String tradeId)? onReject,
}) {
final initEntry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : (trade.entryPrice > 0 ? trade.entryPrice : 100.0);
final initPos = trade.positionSize > 0 ? trade.positionSize : _defaultPositionSize;
final initLev = trade.leverageUsed > 0 ? trade.leverageUsed : _defaultLeverage;
final calcQty = (initEntry > 0 && initPos > 0) ? (initPos / initEntry) : 10.0;
final initQty = trade.quantity > 0 ? trade.quantity : calcQty;
@@ -61,7 +59,6 @@ class TradeExecutionDialog {
final derivativeIsinController = TextEditingController(text: trade.derivativeIsin);
// Normalisierte Zuweisung verhindert den DropdownButton Assertion-Error
String selectedInstrumentType = _normalizeInstrumentType(
trade.instrumentType.isNotEmpty ? trade.instrumentType : 'KnockOut',
);
@@ -117,11 +114,7 @@ class TradeExecutionDialog {
final cleanIsin = inputIsin.trim().toUpperCase();
if (cleanIsin.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bitte gib eine gültige Derivat/Knock-Out ISIN ein.'),
backgroundColor: Colors.amber,
behavior: SnackBarBehavior.floating,
),
const SnackBar(content: Text('Bitte gib eine gültige Derivat/Knock-Out ISIN ein.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating),
);
return;
}
@@ -129,43 +122,36 @@ class TradeExecutionDialog {
setModalState(() => isFetchingDerivativePrice = true);
try {
final apiClient = context.read<ApiClient>();
final res = await apiClient.get('/api/v1/assets/$cleanIsin/technicals?forceRefresh=true');
if (res.statusCode == 200 && res.data != null) {
final Map<String, dynamic> data = res.data;
double? fetchedPrice;
if (data['candles'] is List && (data['candles'] as List).isNotEmpty) {
fetchedPrice = ((data['candles'] as List).last['close'] as num?)?.toDouble();
} else if (data['currentPrice'] != null) {
fetchedPrice = (data['currentPrice'] as num?)?.toDouble();
}
final assetRepo = AssetRepository(apiClient: apiClient);
final technicals = await assetRepo.getAssetTechnical(cleanIsin, true);
if (fetchedPrice != null && fetchedPrice > 0) {
actualEntryController.text = fetchedPrice.toStringAsFixed(2);
recalculateQuantity();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Live-Kurs für Derivat $cleanIsin abgerufen: €${fetchedPrice.toStringAsFixed(2)}'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Kein Kurs für Derivat ISIN $cleanIsin gefunden.'),
backgroundColor: Colors.amber,
behavior: SnackBarBehavior.floating,
),
);
double? fetchedPrice;
if (technicals != null) {
if (technicals.candles.isNotEmpty) {
fetchedPrice = technicals.candles.last.close;
} else if (technicals.currentPrice != null && technicals.currentPrice! > 0) {
fetchedPrice = technicals.currentPrice;
}
}
if (fetchedPrice != null && fetchedPrice > 0) {
actualEntryController.text = fetchedPrice.toStringAsFixed(2);
recalculateQuantity();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Live-Kurs für Derivat $cleanIsin abgerufen: €${fetchedPrice.toStringAsFixed(2)}'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Kein Kurs für Derivat ISIN $cleanIsin gefunden.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Abrufen des Kurses für $cleanIsin via tr_GetPrice: $e'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
SnackBar(content: Text('Fehler beim Abrufen des Kurses für $cleanIsin: $e'), backgroundColor: AppTheme.accentRed, behavior: SnackBarBehavior.floating),
);
} finally {
setModalState(() => isFetchingDerivativePrice = false);
@@ -185,17 +171,11 @@ class TradeExecutionDialog {
selectedInstrumentType.toLowerCase().contains('option') ||
selectedInstrumentType.toLowerCase().contains('cfd');
// Absicherung gegen Assertion-Errors: Stellt sicher, dass der selektierte Wert in der Liste existiert
final safeInstrumentValue = _allowedInstruments.contains(selectedInstrumentType)
? selectedInstrumentType
: 'KnockOut';
final safeInstrumentValue = _allowedInstruments.contains(selectedInstrumentType) ? selectedInstrumentType : 'KnockOut';
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
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),
@@ -215,142 +195,16 @@ class TradeExecutionDialog {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
Text('Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 12),
Builder(
builder: (context) {
final signal = trade.signalType.toUpperCase();
final isLong = signal == 'BUY' || signal == 'LONG';
final signalColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed;
final entryZoneMin = trade.entryZoneMin;
final entryZoneMax = trade.entryZoneMax;
final entryPrice = trade.entryPrice;
final stopLoss = trade.stopLoss;
final takeProfit = trade.takeProfit;
final takeProfitTargets = trade.takeProfitTargets;
final crv = (takeProfit - entryPrice) / (entryPrice - stopLoss).abs();
final maxLeverage = trade.maxLeverage;
final reasoning = trade.reasoning;
final techRationale = trade.technicalRationale;
final fundRationale = trade.fundamentalRationale;
final riskWarning = trade.riskWarning;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
StatusBadge(label: isLong ? 'LONG / KAUFEN' : 'SHORT / VERKAUFEN', color: signalColor),
const SizedBox(width: 8),
if (trade.instrumentType.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6),
),
child: Text(trade.instrumentType, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
if (trade.winRate > 0)
Row(
children: [
Icon(Icons.bolt, size: 14, color: AppTheme.accentCyan),
Text('Win-Rate: ${trade.winRate}%', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
],
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Haltedauer: ${trade.timeframe.isNotEmpty ? trade.timeframe : '1-14 Tage'}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
Text('Risiko: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
if (trade.vixValue > 0)
Text('VIX: ${_fmt(trade.vixValue)} (${trade.vixRegime})', style: const TextStyle(color: Colors.orangeAccent, fontSize: 11, fontWeight: FontWeight.bold)),
],
),
const Divider(color: Colors.white12, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Stop-Loss Target', '${_fmt(stopLoss)}', AppTheme.accentRed),
_buildTradeStat('Take-Profit Target', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '${_fmt(t)}').join(' / ') : '${_fmt(takeProfit)}', AppTheme.primaryEmerald),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (crv > 0) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
if (maxLeverage > 0) _buildTradeStat('Empf. Max Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
_buildTradeStat('Signal Typ', isLong ? 'LONG / BULLISH' : 'SHORT / BEARISH', signalColor),
],
),
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
const SizedBox(height: 10),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
dense: true,
title: Text('Ausführliche KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
children: [
if (reasoning.isNotEmpty) ...[
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
const SizedBox(height: 6),
],
if (techRationale.isNotEmpty) ...[
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
const SizedBox(height: 6),
],
if (fundRationale.isNotEmpty) ...[
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
const SizedBox(height: 6),
],
if (riskWarning.isNotEmpty)
_buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
],
),
],
],
),
);
},
),
TradeExecutionAiPlanCard(trade: trade),
const SizedBox(height: 16),
const Text('Ihre Ausführungsdaten für das Depot:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 10),
// Instrument-Type Dropdown mit abgesichertem Value
DropdownButtonFormField<String>(
value: safeInstrumentValue,
initialValue: safeInstrumentValue,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(
labelText: 'Finanzinstrument Typ',
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
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))),
@@ -359,49 +213,32 @@ class TradeExecutionDialog {
DropdownMenuItem(value: 'Crypto', child: Text('Krypto', style: TextStyle(color: Colors.white, fontSize: 13))),
],
onChanged: (val) {
if (val != null) {
setModalState(() {
selectedInstrumentType = val;
});
}
if (val != null) setModalState(() => selectedInstrumentType = val);
},
),
const SizedBox(height: 10),
if (isKnockout) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: derivativeIsinController,
decoration: const InputDecoration(
labelText: 'Knock-Out / Derivat ISIN (z.B. DE000...)',
hintText: 'ISIN des Hebels eingeben...',
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
decoration: const InputDecoration(labelText: 'Knock-Out / Derivat ISIN (z.B. DE000...)', hintText: 'ISIN des Hebels eingeben...', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: isFetchingDerivativePrice
? null
: () => fetchDerivativePrice(setModalState, derivativeIsinController.text),
onPressed: isFetchingDerivativePrice ? null : () => fetchDerivativePrice(setModalState, derivativeIsinController.text),
icon: isFetchingDerivativePrice
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.bolt, size: 16),
label: const Text('tr_GetPrice'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)),
),
],
),
const SizedBox(height: 10),
],
Row(
children: [
Expanded(
@@ -422,7 +259,6 @@ class TradeExecutionDialog {
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
@@ -443,7 +279,6 @@ class TradeExecutionDialog {
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
@@ -464,7 +299,6 @@ class TradeExecutionDialog {
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
@@ -501,9 +335,7 @@ class TradeExecutionDialog {
},
icon: Icon(Icons.cancel, color: AppTheme.accentRed, size: 16),
label: Text('Trade Ablehnen', style: TextStyle(color: AppTheme.accentRed)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppTheme.accentRed),
),
style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.accentRed)),
),
if (!isActive && onReject != null) const SizedBox(width: 8),
ElevatedButton.icon(
@@ -526,38 +358,4 @@ class TradeExecutionDialog {
},
);
}
static String _fmt(dynamic val) {
if (val == null) return '0.00';
if (val is double) {
if (val > 100) return val.toStringAsFixed(1);
return val.toStringAsFixed(2);
}
return val.toString();
}
static Widget _buildTradeStat(String label, String value, Color color) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11)),
const SizedBox(height: 2),
Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)),
],
);
}
static Widget _buildRationaleBlock(String title, String content, Color color) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(content, style: const TextStyle(color: Colors.white70, fontSize: 12, height: 1.4)),
],
),
);
}
}