diff --git a/FinlyticApp/lib/features/asset_detail/bloc/header/asset_header_bloc.dart b/FinlyticApp/lib/features/asset_detail/bloc/header/asset_header_bloc.dart index ec98bfa..59f31b5 100644 --- a/FinlyticApp/lib/features/asset_detail/bloc/header/asset_header_bloc.dart +++ b/FinlyticApp/lib/features/asset_detail/bloc/header/asset_header_bloc.dart @@ -1,8 +1,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../models/asset_model.dart'; +import '../../models/fundamental_data_model.dart'; +import '../../models/technical_analysis_model.dart'; +import '../../repositories/asset_repository.dart'; import 'asset_header_event.dart'; import 'asset_header_state.dart'; -import '../../repositories/asset_repository.dart'; -import '../../models/asset_model.dart'; class AssetHeaderBloc extends Bloc { final AssetRepository repository; @@ -11,21 +13,41 @@ class AssetHeaderBloc extends Bloc { final prevData = state is AssetHeaderLoaded ? (state as AssetHeaderLoaded).data : (state is AssetHeaderLoading ? (state as AssetHeaderLoading).previousData : null); emit(AssetHeaderLoading(previousData: prevData)); try { - final fundamentals = await repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker); + final results = await Future.wait([ + repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker), + repository.getAssetTechnical(event.isin, event.forceRefresh, ticker: event.ticker), + ]); + + final fundamentals = results[0] as FundamentalDataModel?; + final technical = results[1] as TechnicalAnalysisModel?; + if (fundamentals != null) { + double initialPrice = fundamentals.currentPrice; + String initialCurrency = fundamentals.tradingCurrency ?? 'EUR'; + + if (technical != null && technical.candles.isNotEmpty) { + final lastClose = technical.candles.last.close; + if (lastClose > 0) { + initialPrice = lastClose; + } + if (technical.currency.isNotEmpty) { + initialCurrency = technical.currency; + } + } + final assetModel = AssetModel( isin: fundamentals.isin, symbol: fundamentals.primaryTicker.isNotEmpty ? fundamentals.primaryTicker : fundamentals.isin, name: fundamentals.companyName, - currentPrice: fundamentals.currentPrice, - currency: fundamentals.tradingCurrency ?? 'EUR', + currentPrice: initialPrice, + currency: initialCurrency, exchange: fundamentals.exchange ?? 'XETRA', - exchanges: [], // Can be populated if needed + exchanges: [], tickers: fundamentals.availableTickers.map((t) => AssetTickerOption( ticker: t.ticker, exchange: t.exchange ?? 'Unknown', - tradingCurrency: t.tradingCurrency ?? fundamentals.tradingCurrency ?? 'EUR', - currentPrice: t.currentPrice, + tradingCurrency: t.tradingCurrency ?? initialCurrency, + currentPrice: t.currentPrice ?? initialPrice, )).toList(), image: '/api/v1/logo/${fundamentals.isin}', ); diff --git a/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_bloc.dart b/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_bloc.dart index 53044fc..23cedeb 100644 --- a/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_bloc.dart +++ b/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_bloc.dart @@ -1,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../trades/models/trade_model.dart'; import 'asset_trades_event.dart'; import 'asset_trades_state.dart'; import '../../repositories/asset_repository.dart'; @@ -17,9 +18,20 @@ class AssetTradesBloc extends Bloc { } }); on((event, emit) async { + emit(AssetTradesLoading()); try { - await repository.triggerManualAnalysis(event.isin, payload: event.payload); - add(LoadAssetTrades(event.isin)); + final analysisRes = await repository.triggerManualAnalysis(event.isin, payload: event.payload); + final existingTrades = await repository.getAssetTrades(event.isin, null); + + final list = List.from(existingTrades); + final newProposal = analysisRes?.proposal; + if (newProposal != null) { + final isDuplicate = list.any((t) => t.id == newProposal.id || (t.analysisId.isNotEmpty && t.analysisId == newProposal.analysisId)); + if (!isDuplicate) { + list.insert(0, newProposal); + } + } + emit(AssetTradesLoaded(list)); } catch (e) { emit(AssetTradesError("Failed to trigger manual analysis: $e")); } diff --git a/FinlyticApp/lib/features/asset_detail/models/fundamental_data_model.dart b/FinlyticApp/lib/features/asset_detail/models/fundamental_data_model.dart index a22f3ed..8e62a0b 100644 --- a/FinlyticApp/lib/features/asset_detail/models/fundamental_data_model.dart +++ b/FinlyticApp/lib/features/asset_detail/models/fundamental_data_model.dart @@ -29,6 +29,16 @@ class FundamentalDataModel extends Equatable { final double? evToEbitda; final double? evToRevenue; + final double? totalRevenue; + final double? revenueGrowthYoY; + final double? grossProfit; + final double? ebitda; + final double? dilutedEps; + final double? totalCash; + final double? totalDebt; + final double? operatingCashFlow; + final double? freeCashFlow; + final double? grossMargin; final double? operatingMargin; final double? netProfitMargin; @@ -86,6 +96,15 @@ class FundamentalDataModel extends Equatable { this.psRatio, this.evToEbitda, this.evToRevenue, + this.totalRevenue, + this.revenueGrowthYoY, + this.grossProfit, + this.ebitda, + this.dilutedEps, + this.totalCash, + this.totalDebt, + this.operatingCashFlow, + this.freeCashFlow, this.grossMargin, this.operatingMargin, this.netProfitMargin, @@ -128,14 +147,121 @@ class FundamentalDataModel extends Equatable { return double.tryParse(val.toString()); } + final assetMap = json['asset'] is Map ? json['asset'] as Map : null; + final fundMap = json['fundamentals'] is Map ? json['fundamentals'] as Map : null; + + String extractTickerStr(dynamic val) { + if (val == null) return ''; + if (val is Map) { + return val['ticker']?.toString() ?? ''; + } + return val.toString(); + } + + String? extractExchangeStr(dynamic val) { + if (val == null) return null; + if (val is Map) { + return val['exchange']?.toString(); + } + return null; + } + + final isinVal = assetMap?['isin']?.toString() ?? json['isin']?.toString() ?? ''; + final primaryTickerVal = extractTickerStr(assetMap?['primaryTicker'] ?? json['primaryTicker']); + final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty + ? extractTickerStr(fundMap?['ticker'] ?? json['ticker']) + : primaryTickerVal; + final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? json['name']?.toString() ?? tickerVal; + final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString() ?? json['description']?.toString(); + + final exchangeVal = extractExchangeStr(fundMap?['ticker']) ?? + extractExchangeStr(assetMap?['primaryTicker']) ?? + json['exchange']?.toString(); + + final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers']; + List availableTickersList = []; + if (rawTickers is List) { + availableTickersList = rawTickers.map((t) { + if (t is Map) { + return TickerModel.fromJson(t); + } else { + return TickerModel(ticker: t.toString()); + } + }).toList(); + } + + // Revenue & Margins Derivation + final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']); + final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']); + double? grossMarginVal = parseNullableDouble(fundMap?['grossMargin'] ?? json['grossMargin']); + if (grossMarginVal == null && grossProf != null) { + if (grossProf <= 1.0 && grossProf >= 0.0) { + grossMarginVal = grossProf; + } else if (totalRev != null && totalRev > 0) { + grossMarginVal = grossProf / totalRev; + } + } + + // Enterprise Value to Revenue + final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']); + double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']); + if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) { + evToRevVal = evVal / totalRev; + } + + // Event Dates (Ex-Dividend & Next Earnings) + String? exDividendDateVal = json['exDividendDate']?.toString() ?? fundMap?['exDividendDate']?.toString(); + String? nextEarningsDateVal = json['nextEarningsDate']?.toString() ?? fundMap?['nextEarningsDate']?.toString(); + + final rawEvents = json['events']; + if (rawEvents is List && rawEvents.isNotEmpty) { + final now = DateTime.now(); + final parsedEvents = >[]; + for (final ev in rawEvents) { + if (ev is Map) { + final dtStr = ev['date']?.toString(); + final dt = dtStr != null ? DateTime.tryParse(dtStr) : null; + if (dt != null) { + parsedEvents.add({ + 'type': ev['type']?.toString().toUpperCase() ?? '', + 'date': dt, + 'dateStr': dtStr, + }); + } + } + } + + if (exDividendDateVal == null) { + final dividendEvents = parsedEvents.where((e) => e['type'] == 'DIVIDEND').toList() + ..sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime)); + final futureDividends = dividendEvents.where((e) => (e['date'] as DateTime).isAfter(now)).toList(); + if (futureDividends.isNotEmpty) { + exDividendDateVal = futureDividends.first['dateStr'] as String; + } else if (dividendEvents.isNotEmpty) { + exDividendDateVal = dividendEvents.last['dateStr'] as String; + } + } + + if (nextEarningsDateVal == null) { + final earningsEvents = parsedEvents.where((e) => e['type'] == 'EARNINGS_RELEASE' || e['type'] == 'EARNINGS_CALL').toList() + ..sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime)); + final futureEarnings = earningsEvents.where((e) => (e['date'] as DateTime).isAfter(now)).toList(); + if (futureEarnings.isNotEmpty) { + nextEarningsDateVal = futureEarnings.first['dateStr'] as String; + } else if (earningsEvents.isNotEmpty) { + nextEarningsDateVal = earningsEvents.last['dateStr'] as String; + } + } + } + return FundamentalDataModel( - isin: json['isin']?.toString() ?? '', - primaryTicker: json['primaryTicker']?.toString() ?? '', - ticker: json['ticker']?.toString() ?? '', - companyName: json['companyName']?.toString() ?? '', - exchange: json['exchange']?.toString(), + isin: isinVal, + primaryTicker: primaryTickerVal, + ticker: tickerVal, + companyName: companyNameVal, + exchange: exchangeVal, tradingCurrency: json['tradingCurrency']?.toString(), - businessSummary: json['businessSummary']?.toString(), + businessSummary: businessSummaryVal, sector: json['sector']?.toString(), industry: json['industry']?.toString(), country: json['country']?.toString(), @@ -143,56 +269,62 @@ class FundamentalDataModel extends Equatable { currentPrice: parseDouble(json['currentPrice']), dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']), dayChangePercent: parseDouble(json['dayChangePercent']), - fiftyTwoWeekHigh: parseDouble(json['fiftyTwoWeekHigh']), - fiftyTwoWeekLow: parseDouble(json['fiftyTwoWeekLow']), - marketCapitalization: parseDouble(json['marketCapitalization'] ?? json['marketCap']), - enterpriseValue: parseDouble(json['enterpriseValue']), - peRatioTrailing: parseNullableDouble(json['peRatioTrailing'] ?? json['peRatio']), - peRatioForward: parseNullableDouble(json['peRatioForward']), - pegRatio: parseNullableDouble(json['pegRatio']), - pbRatio: parseNullableDouble(json['pbRatio']), - psRatio: parseNullableDouble(json['psRatio']), - evToEbitda: parseNullableDouble(json['evToEbitda']), - evToRevenue: parseNullableDouble(json['evToRevenue']), - grossMargin: parseNullableDouble(json['grossMargin']), - operatingMargin: parseNullableDouble(json['operatingMargin']), - netProfitMargin: parseNullableDouble(json['netProfitMargin']), - returnOnEquity: parseNullableDouble(json['returnOnEquity']), - returnOnAssets: parseNullableDouble(json['returnOnAssets']), - returnOnInvestedCapital: parseNullableDouble(json['returnOnInvestedCapital']), - debtToEquity: parseNullableDouble(json['debtToEquity']), - currentRatio: parseNullableDouble(json['currentRatio']), - quickRatio: parseNullableDouble(json['quickRatio']), - interestCoverage: parseNullableDouble(json['interestCoverage']), - dividendYield: parseNullableDouble(json['dividendYield']), - payoutRatio: parseNullableDouble(json['payoutRatio']), - exDividendDate: json['exDividendDate']?.toString(), - nextEarningsDate: json['nextEarningsDate']?.toString(), - percentHeldByInstitutions: parseNullableDouble(json['percentHeldByInstitutions']), - percentHeldByInsiders: parseNullableDouble(json['percentHeldByInsiders']), - shortRatio: parseNullableDouble(json['shortRatio']), - shortPercentOfFloat: parseNullableDouble(json['shortPercentOfFloat']), - consensusRating: json['consensusRating']?.toString(), - priceTargetLow: parseNullableDouble(json['priceTargetLow']), - priceTargetHigh: parseNullableDouble(json['priceTargetHigh']), - priceTargetMedian: parseNullableDouble(json['priceTargetMedian']), - priceTargetMean: parseNullableDouble(json['priceTargetMean']), + fiftyTwoWeekHigh: parseDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']), + fiftyTwoWeekLow: parseDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']), + marketCapitalization: parseDouble(fundMap?['marketCap'] ?? json['marketCapitalization'] ?? json['marketCap']), + enterpriseValue: parseDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']), + peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? json['peRatioTrailing'] ?? json['peRatio']), + peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? json['peRatioForward']), + pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']), + pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? json['pbRatio']), + psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? json['psRatio']), + evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? json['evToEbitda']), + evToRevenue: evToRevVal, + totalRevenue: totalRev, + revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']), + grossProfit: grossProf, + ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']), + dilutedEps: parseNullableDouble(fundMap?['dilutedEps'] ?? json['dilutedEps']), + totalCash: parseNullableDouble(fundMap?['totalCash'] ?? json['totalCash']), + totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']), + operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']), + freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? json['freeCashFlow']), + grossMargin: grossMarginVal, + operatingMargin: parseNullableDouble(fundMap?['operatingMargin'] ?? fundMap?['operatingIncome'] ?? json['operatingMargin']), + netProfitMargin: parseNullableDouble(fundMap?['netProfitMargin'] ?? fundMap?['netIncome'] ?? json['netProfitMargin']), + returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']), + returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']), + returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']), + debtToEquity: parseNullableDouble(fundMap?['debtToEquity'] ?? json['debtToEquity']), + currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']), + quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']), + interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']), + dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? json['dividendYield']), + payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']), + exDividendDate: exDividendDateVal, + nextEarningsDate: nextEarningsDateVal, + percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']), + percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']), + shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']), + shortPercentOfFloat: parseNullableDouble(fundMap?['shortPercentOfFloat'] ?? json['shortPercentOfFloat']), + consensusRating: (fundMap?['consensusRating'] ?? json['consensusRating'])?.toString(), + priceTargetLow: parseNullableDouble(fundMap?['priceTargetLow'] ?? json['priceTargetLow']), + priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']), + priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']), + priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']), executives: (json['executives'] as List?) - ?.map((e) => CompanyExecutiveModel.fromJson(e)) + ?.map((e) => CompanyExecutiveModel.fromJson(e is Map ? e : {})) .toList() ?? [], financialStatements: (json['financialStatements'] as List?) - ?.map((e) => FinancialStatementModel.fromJson(e)) + ?.map((e) => FinancialStatementModel.fromJson(e is Map ? e : {})) .toList() ?? [], estimates: (json['estimates'] as List?) - ?.map((e) => ForwardEstimateModel.fromJson(e)) - .toList() ?? - [], - availableTickers: (json['availableTickers'] as List?) - ?.map((e) => TickerModel.fromJson(e)) + ?.map((e) => ForwardEstimateModel.fromJson(e is Map ? e : {})) .toList() ?? [], + availableTickers: availableTickersList, ); } @@ -322,11 +454,30 @@ class CompanyExecutiveModel extends Equatable { }); factory CompanyExecutiveModel.fromJson(Map json) { + double? compVal; + if (json['compensation'] != null) { + compVal = double.tryParse(json['compensation'].toString()); + } else if (json['payment'] != null) { + final pStr = json['payment'].toString().trim().toUpperCase().replaceAll('\$', '').replaceAll('€', '').replaceAll('£', '').replaceAll(',', '').replaceAll(' ', ''); + if (pStr.endsWith('M')) { + final numPart = double.tryParse(pStr.substring(0, pStr.length - 1)); + if (numPart != null) compVal = numPart * 1e6; + } else if (pStr.endsWith('K')) { + final numPart = double.tryParse(pStr.substring(0, pStr.length - 1)); + if (numPart != null) compVal = numPart * 1e3; + } else if (pStr.endsWith('B')) { + final numPart = double.tryParse(pStr.substring(0, pStr.length - 1)); + if (numPart != null) compVal = numPart * 1e9; + } else { + compVal = double.tryParse(pStr); + } + } + return CompanyExecutiveModel( name: json['name']?.toString() ?? '', title: json['title']?.toString() ?? '', age: json['age'] != null ? int.tryParse(json['age'].toString()) : null, - compensation: json['compensation'] != null ? double.tryParse(json['compensation'].toString()) : null, + compensation: compVal, ); } @@ -545,7 +696,7 @@ class TickerModel extends Equatable { required this.ticker, this.exchange, this.tradingCurrency, - required this.currentPrice, + this.currentPrice = 0.0, }); factory TickerModel.fromJson(Map json) { diff --git a/FinlyticApp/lib/features/asset_detail/models/manual_analysis_response_dto.dart b/FinlyticApp/lib/features/asset_detail/models/manual_analysis_response_dto.dart new file mode 100644 index 0000000..6566606 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/models/manual_analysis_response_dto.dart @@ -0,0 +1,180 @@ +import 'package:equatable/equatable.dart'; +import '../../trades/models/trade_model.dart'; + +class ExecutionPlanModel extends Equatable { + final double stopLoss; + final List takeProfitTargets; + final double riskRewardRatio; + final double maxLeverage; + + const ExecutionPlanModel({ + this.stopLoss = 0.0, + this.takeProfitTargets = const [], + this.riskRewardRatio = 0.0, + this.maxLeverage = 1.0, + }); + + factory ExecutionPlanModel.fromJson(Map json) { + double parseDbl(dynamic v) => (v as num?)?.toDouble() ?? 0.0; + return ExecutionPlanModel( + stopLoss: parseDbl(json['stopLoss']), + takeProfitTargets: (json['takeProfitTargets'] as List? ?? []).map((e) => parseDbl(e)).toList(), + riskRewardRatio: parseDbl(json['riskRewardRatio']), + maxLeverage: parseDbl(json['maxLeverage']) == 0 ? 1.0 : parseDbl(json['maxLeverage']), + ); + } + + @override + List get props => [stopLoss, takeProfitTargets, riskRewardRatio, maxLeverage]; +} + +class DetailedAnalysisModel extends Equatable { + final String technicalRationale; + final String fundamentalRationale; + final String riskWarning; + + const DetailedAnalysisModel({ + this.technicalRationale = '', + this.fundamentalRationale = '', + this.riskWarning = '', + }); + + factory DetailedAnalysisModel.fromJson(Map json) { + return DetailedAnalysisModel( + technicalRationale: json['technicalRationale']?.toString() ?? '', + fundamentalRationale: json['fundamentalRationale']?.toString() ?? '', + riskWarning: json['riskWarning']?.toString() ?? '', + ); + } + + @override + List get props => [technicalRationale, fundamentalRationale, riskWarning]; +} + +class N8nAnalysisResponseDto extends Equatable { + final String aiDecision; // "Proceed", "Rejected", "Hold" + final String aiReasoning; + final int evalScore; + final String suggestedDirection; // "Long", "Short" + final String suggestedRisk; + final String suggestedTimeframe; + final ExecutionPlanModel? executionPlan; + final DetailedAnalysisModel? detailedAnalysis; + + const N8nAnalysisResponseDto({ + this.aiDecision = 'Rejected', + this.aiReasoning = '', + this.evalScore = 0, + this.suggestedDirection = 'Long', + this.suggestedRisk = 'Moderate', + this.suggestedTimeframe = '1D', + this.executionPlan, + this.detailedAnalysis, + }); + + factory N8nAnalysisResponseDto.fromJson(Map json) { + ExecutionPlanModel? execPlan; + if (json['executionPlan'] != null && json['executionPlan'] is Map) { + execPlan = ExecutionPlanModel.fromJson(json['executionPlan']); + } + + DetailedAnalysisModel? detailAnalysis; + if (json['detailedAnalysis'] != null && json['detailedAnalysis'] is Map) { + detailAnalysis = DetailedAnalysisModel.fromJson(json['detailedAnalysis']); + } + + return N8nAnalysisResponseDto( + aiDecision: json['aiDecision']?.toString() ?? 'Rejected', + aiReasoning: json['aiReasoning']?.toString() ?? '', + evalScore: (json['evalScore'] as num?)?.toInt() ?? 0, + suggestedDirection: json['suggestedDirection']?.toString() ?? 'Long', + suggestedRisk: json['suggestedRisk']?.toString() ?? 'Moderate', + suggestedTimeframe: json['suggestedTimeframe']?.toString() ?? '1D', + executionPlan: execPlan, + detailedAnalysis: detailAnalysis, + ); + } + + @override + List get props => [ + aiDecision, + aiReasoning, + evalScore, + suggestedDirection, + suggestedRisk, + suggestedTimeframe, + executionPlan, + detailedAnalysis, + ]; +} + +class ManualAnalysisResponseDto extends Equatable { + final String analysisId; + final bool isTradeProposed; + final String status; + final String recommendation; // "RECOMMENDED", "NOT_RECOMMENDED" + final N8nAnalysisResponseDto? n8nResponse; + final TradeModel? proposal; + final String message; + + const ManualAnalysisResponseDto({ + required this.analysisId, + this.isTradeProposed = false, + this.status = 'Success', + this.recommendation = 'NOT_RECOMMENDED', + this.n8nResponse, + this.proposal, + this.message = '', + }); + + factory ManualAnalysisResponseDto.fromJson(Map json) { + N8nAnalysisResponseDto? n8n; + if (json['n8nResponse'] != null && json['n8nResponse'] is Map) { + n8n = N8nAnalysisResponseDto.fromJson(json['n8nResponse']); + } + + TradeModel? prop; + if (json['proposal'] != null && json['proposal'] is Map) { + prop = TradeModel.fromJson(json['proposal']); + } else if (n8n != null) { + final exec = n8n.executionPlan; + final det = n8n.detailedAnalysis; + final isProceed = n8n.aiDecision.toLowerCase() == 'proceed'; + final analysisIdStr = (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? ''; + final tradeIdStr = 'PROP-${analysisIdStr.length > 10 ? analysisIdStr.substring(0, 10).toUpperCase() : 'MANUAL'}'; + + prop = TradeModel( + id: tradeIdStr, + analysisId: analysisIdStr, + symbol: (json['symbol'] ?? json['Symbol'])?.toString() ?? '', + isin: (json['isin'] ?? json['Isin'])?.toString() ?? '', + status: isProceed ? 'Proposed' : 'Rejected', + signalType: n8n.suggestedDirection.toUpperCase() == 'SHORT' ? 'SELL' : 'BUY', + entryPrice: 0.0, + stopLoss: exec?.stopLoss ?? 0.0, + takeProfit: (exec?.takeProfitTargets.isNotEmpty ?? false) ? exec!.takeProfitTargets.first : 0.0, + reasoning: n8n.aiReasoning, + technicalRationale: det?.technicalRationale ?? '', + fundamentalRationale: det?.fundamentalRationale ?? '', + riskWarning: det?.riskWarning ?? '', + takeProfitTargets: exec?.takeProfitTargets ?? const [], + maxLeverage: exec?.maxLeverage ?? 1.0, + riskTolerance: n8n.suggestedRisk, + timeframe: n8n.suggestedTimeframe, + ); + } + + return ManualAnalysisResponseDto( + analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '', + isTradeProposed: json['isTradeProposed'] == true || json['IsTradeProposed'] == true, + status: (json['status'] ?? json['Status'])?.toString() ?? 'Success', + recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'NOT_RECOMMENDED', + n8nResponse: n8n, + proposal: prop, + message: (json['message'] ?? json['Message'])?.toString() ?? '', + ); + } + + @override + List get props => [analysisId, isTradeProposed, status, recommendation, n8nResponse, proposal, message]; +} diff --git a/FinlyticApp/lib/features/asset_detail/models/technical_analysis_model.dart b/FinlyticApp/lib/features/asset_detail/models/technical_analysis_model.dart index 3b891e1..b4c5c13 100644 --- a/FinlyticApp/lib/features/asset_detail/models/technical_analysis_model.dart +++ b/FinlyticApp/lib/features/asset_detail/models/technical_analysis_model.dart @@ -106,11 +106,16 @@ class StrategySignalModel extends Equatable { }); factory StrategySignalModel.fromJson(Map json) { + final rawDir = (json['direction'] ?? json['signalType'] ?? json['type'])?.toString().toUpperCase() ?? 'BUY'; + final sigDir = (rawDir == 'BUY' || rawDir == 'SELL') ? rawDir : 'BUY'; + final sigTitle = (json['title'] ?? json['type'] ?? json['description'])?.toString() ?? 'Signal'; + final dateStr = (json['timestamp'] ?? json['date'] ?? json['time'])?.toString(); + return StrategySignalModel( - title: json['title']?.toString() ?? '', - date: DateTime.tryParse(json['date']?.toString() ?? '') ?? DateTime.now(), + title: sigTitle, + date: dateStr != null ? (DateTime.tryParse(dateStr) ?? DateTime.now()) : DateTime.now(), price: (json['price'] as num?)?.toDouble() ?? 0.0, - type: json['type']?.toString() ?? 'BUY', + type: sigDir, ); } @@ -123,33 +128,76 @@ class PatternPoint extends Equatable { final double price; const PatternPoint(this.time, this.price); - factory PatternPoint.fromJson(Map json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble()); + factory PatternPoint.fromJson(Map json) => PatternPoint(DateTime.tryParse(json['time']?.toString() ?? '') ?? DateTime.now(), (json['price'] as num?)?.toDouble() ?? 0.0); @override List get props => [time, price]; } -class ChartPatternModel extends Equatable { - final String type; - final List upperLine; - final List lowerLine; +class BreakoutSignalModel extends Equatable { + final String direction; // "UP", "DOWN" + final double targetPrice; + final double potentialPercent; - const ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine}); + const BreakoutSignalModel({ + required this.direction, + required this.targetPrice, + required this.potentialPercent, + }); - factory ChartPatternModel.fromJson(Map json) { - return ChartPatternModel( - type: json['type']?.toString() ?? 'Pattern', - upperLine: (json['upperLine'] as List? ?? []).map((e) => PatternPoint.fromJson(e)).toList(), - lowerLine: (json['lowerLine'] as List? ?? []).map((e) => PatternPoint.fromJson(e)).toList(), + factory BreakoutSignalModel.fromJson(Map json) { + return BreakoutSignalModel( + direction: (json['direction'] ?? json['Direction'])?.toString() ?? 'UP', + targetPrice: (json['targetPrice'] ?? json['TargetPrice'] as num?)?.toDouble() ?? 0.0, + potentialPercent: (json['potentialPercent'] ?? json['PotentialPercent'] as num?)?.toDouble() ?? 0.0, ); } @override - List get props => [type, upperLine, lowerLine]; + List get props => [direction, targetPrice, potentialPercent]; +} + +class ChartPatternModel extends Equatable { + final String type; + final String description; + final double confidencePercent; + final BreakoutSignalModel? breakoutSignal; + final List upperLine; + final List lowerLine; + + const ChartPatternModel({ + required this.type, + this.description = '', + this.confidencePercent = 0.0, + this.breakoutSignal, + required this.upperLine, + required this.lowerLine, + }); + + factory ChartPatternModel.fromJson(Map json) { + BreakoutSignalModel? breakout; + final bJson = json['breakoutSignal'] ?? json['BreakoutSignal']; + if (bJson != null && bJson is Map) { + breakout = BreakoutSignalModel.fromJson(bJson); + } + + return ChartPatternModel( + type: json['type']?.toString() ?? json['Type']?.toString() ?? 'Pattern', + description: json['description']?.toString() ?? json['Description']?.toString() ?? '', + confidencePercent: (json['confidencePercent'] ?? json['ConfidencePercent'] as num?)?.toDouble() ?? 0.0, + breakoutSignal: breakout, + upperLine: (json['upperLine'] as List? ?? []).map((e) => PatternPoint.fromJson(e as Map)).toList(), + lowerLine: (json['lowerLine'] as List? ?? []).map((e) => PatternPoint.fromJson(e as Map)).toList(), + ); + } + + @override + List get props => [type, description, confidencePercent, breakoutSignal, upperLine, lowerLine]; } class TechnicalAnalysisModel extends Equatable { final String symbol; + final String currency; final String trend; final String rsi; final String macd; @@ -167,6 +215,7 @@ class TechnicalAnalysisModel extends Equatable { const TechnicalAnalysisModel({ required this.symbol, + this.currency = 'EUR', required this.trend, required this.rsi, required this.macd, @@ -211,6 +260,7 @@ class TechnicalAnalysisModel extends Equatable { return TechnicalAnalysisModel( symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '', + currency: json['currency']?.toString() ?? 'EUR', trend: parsedTrend, rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A', macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A', @@ -231,6 +281,7 @@ class TechnicalAnalysisModel extends Equatable { Map toJson() { return { 'symbol': symbol, + 'currency': currency, 'trend': trend, 'rsi': rsi, 'macd': macd, @@ -246,7 +297,7 @@ class TechnicalAnalysisModel extends Equatable { @override List get props => [ - symbol, trend, rsi, macd, overallSignal, sma50, sma200, vix, + symbol, currency, trend, rsi, macd, overallSignal, sma50, sma200, vix, sp500Trend, dxy, stopLossAtr, candles, indicators, patterns, signals ]; } diff --git a/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart b/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart index 20c9249..3e96c21 100644 --- a/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart +++ b/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart @@ -3,6 +3,7 @@ import 'package:finlytic_app/core/network/api_client.dart'; import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart'; import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart'; import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart'; +import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_dto.dart'; import 'package:finlytic_app/features/trades/models/trade_model.dart'; import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart'; @@ -58,13 +59,17 @@ class AssetRepository { return []; } - Future triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async { + Future triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async { try { final body = payload != null ? payload.toJson() : {'isin': isin}; - await apiClient.post('/api/v1/analyze/manual', data: body); + final res = await apiClient.post('/api/v1/analyze/manual', data: body); + if (res.statusCode == 200 && res.data != null && res.data is Map) { + return ManualAnalysisResponseDto.fromJson(res.data); + } + return null; } catch (e) { print('Error triggering manual analysis for $isin: $e'); - throw e; + rethrow; } } diff --git a/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_desktop_layout.dart b/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_desktop_layout.dart index a569c8e..47b92cc 100644 --- a/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_desktop_layout.dart +++ b/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_desktop_layout.dart @@ -37,7 +37,7 @@ class _AssetPageDesktopLayoutState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); } @override @@ -69,8 +69,6 @@ class _AssetPageDesktopLayoutState extends State forceRefresh: true, exchange: _selectedExchange, ticker: _selectedTicker)); - // AssetFundamentalsBloc is omitted here because AssetHeaderBloc already triggers forceRefresh=true - // for fundamentals, and the listener below will fetch the updated data with forceRefresh=false. context.read().add(LoadAssetTechnical(widget.isin, ticker: _selectedTicker, forceRefresh: true)); context.read().add(LoadAssetTrades(widget.isin)); @@ -86,10 +84,8 @@ class _AssetPageDesktopLayoutState extends State if (_selectedTicker == null) { setState(() { _selectedTicker = widget.selectedTicker; - //_selectedExchange = state.data!.exchange; }); } - // Re-trigger fundamentals and TA with resolved ticker whenever header loads (e.g. after force refresh) context.read().add(LoadAssetFundamentals( widget.isin, ticker: _selectedTicker, @@ -100,92 +96,101 @@ class _AssetPageDesktopLayoutState extends State forceRefresh: false)); } }, - child: LayoutBuilder( - builder: (context, constraints) { - final height = constraints.maxHeight.isFinite - ? constraints.maxHeight - : MediaQuery.of(context).size.height; - return SizedBox( - height: height, - width: double.infinity, - child: Column( - children: [ - AssetHeroHeader( - isin: widget.isin, - name: widget.name ?? widget.isin, - symbol: _selectedTicker ?? widget.selectedTicker, - onExchangeChanged: _handleExchangeChanged, - onForceRefresh: _handleForceRefresh, - ), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Left Panel (Chart Focus) - Expanded( - flex: 5, - child: Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.cardSurface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: theme.glassBorder), - ), - child: TechnicalTab( - isin: widget.isin, - symbol: _selectedTicker, - isDesktopLeftPanel: true), - ), - ), - // Right Panel (Tabs for fundamentals/trades) - Expanded( - flex: 3, - child: Container( - margin: const EdgeInsets.only( - top: 16, right: 16, bottom: 16), - decoration: BoxDecoration( - color: theme.cardSurface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: theme.glassBorder), - ), - child: Column( - children: [ - TabBar( - controller: _tabController, - labelColor: theme.primaryColor, - unselectedLabelColor: theme.textMuted, - indicatorColor: theme.primaryColor, - dividerColor: theme.glassBorder, - labelStyle: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 13), - tabs: const [ - Tab(text: 'OVERVIEW'), - Tab(text: 'TRADES'), - ], - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - FundamentalsTab( - isin: widget.isin, - symbol: _selectedTicker, - ), - TradesTab(symbol: widget.isin), - ], - ), - ), - ], - ), - ), - ), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Hero Header + AssetHeroHeader( + isin: widget.isin, + name: widget.name ?? widget.isin, + symbol: _selectedTicker ?? widget.selectedTicker, + onExchangeChanged: _handleExchangeChanged, + onForceRefresh: _handleForceRefresh, + ), + + // 2. Full-Width Interactive Chart Section + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: theme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.glassBorder), + ), + child: TechnicalTab( + isin: widget.isin, + symbol: _selectedTicker, + showChartOnly: true, + chartHeight: 460, + ), + ), + + const SizedBox(height: 8), + + // 3. Detailed Sections & Fundamentals under the Chart + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: theme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + controller: _tabController, + labelColor: theme.primaryColor, + unselectedLabelColor: theme.textMuted, + indicatorColor: theme.primaryColor, + dividerColor: theme.glassBorder, + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 13), + tabs: const [ + Tab( + icon: Icon(Icons.analytics_outlined, size: 18), + text: 'FUNDAMENTALS & ÜBERSICHT'), + Tab( + icon: Icon(Icons.architecture_outlined, size: 18), + text: 'MUSTER & SIGNALE'), + Tab( + icon: Icon(Icons.candlestick_chart_outlined, size: 18), + text: 'TRADES'), ], ), - ), - ], + AnimatedBuilder( + animation: _tabController, + builder: (context, _) { + switch (_tabController.index) { + case 0: + return FundamentalsTab( + isin: widget.isin, + symbol: _selectedTicker, + isEmbedded: true, + ); + case 1: + return TechnicalTab( + isin: widget.isin, + symbol: _selectedTicker, + showDetailsOnly: true, + ); + case 2: + return SizedBox( + height: 600, + child: TradesTab(symbol: widget.isin), + ); + default: + return const SizedBox.shrink(); + } + }, + ), + ], + ), ), - ); - }, + const SizedBox(height: 24), + ], + ), ), ); } diff --git a/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_mobile_layout.dart b/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_mobile_layout.dart index 1906b15..6a761b8 100644 --- a/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_mobile_layout.dart +++ b/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_mobile_layout.dart @@ -84,10 +84,8 @@ class _AssetPageMobileLayoutState extends State if (_selectedTicker == null) { setState(() { _selectedTicker = widget.selectedTicker; - //_selectedExchange = state.data!.exchange; }); } - // Re-trigger fundamentals and TA with resolved ticker whenever header loads (e.g. after force refresh) context.read().add(LoadAssetFundamentals( widget.isin, ticker: _selectedTicker, @@ -98,82 +96,96 @@ class _AssetPageMobileLayoutState extends State forceRefresh: false)); } }, - child: NestedScrollView( - headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SliverToBoxAdapter( - child: AssetHeroHeader( - isin: widget.isin, - name: widget.name ?? widget.isin, - symbol: _selectedTicker ?? widget.selectedTicker, - onExchangeChanged: _handleExchangeChanged, - onForceRefresh: _handleForceRefresh, - ), - ), - SliverPersistentHeader( - pinned: true, - delegate: _SliverAppBarDelegate( - TabBar( - controller: _tabController, - labelColor: theme.primaryColor, - unselectedLabelColor: theme.textMuted, - indicatorColor: theme.primaryColor, - dividerColor: Colors.transparent, - labelStyle: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 13), - tabs: const [ - Tab(text: 'OVERVIEW'), - Tab(text: 'TECHNICAL'), - Tab(text: 'TRADES'), - ], - ), - theme.cardSurface, - ), - ), - ]; - }, - body: TabBarView( - controller: _tabController, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - FundamentalsTab( + // 1. Hero Header + AssetHeroHeader( isin: widget.isin, - symbol: _selectedTicker, + name: widget.name ?? widget.isin, + symbol: _selectedTicker ?? widget.selectedTicker, + onExchangeChanged: _handleExchangeChanged, + onForceRefresh: _handleForceRefresh, ), - TechnicalTab( - isin: widget.isin, - symbol: _selectedTicker, + + // 2. Full-Width Interactive Chart Section + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: theme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.glassBorder), + ), + child: TechnicalTab( + isin: widget.isin, + symbol: _selectedTicker, + showChartOnly: true, + chartHeight: 330, + ), ), - TradesTab(symbol: widget.isin), + + const SizedBox(height: 6), + + // 3. Tab Bar & Detailed Sections (Fundamentals, Signals, Trades) + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: theme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + controller: _tabController, + labelColor: theme.primaryColor, + unselectedLabelColor: theme.textMuted, + indicatorColor: theme.primaryColor, + dividerColor: theme.glassBorder, + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 12), + tabs: const [ + Tab(text: 'FUNDAMENTALS'), + Tab(text: 'MUSTER & SIGNALE'), + Tab(text: 'TRADES'), + ], + ), + AnimatedBuilder( + animation: _tabController, + builder: (context, _) { + switch (_tabController.index) { + case 0: + return FundamentalsTab( + isin: widget.isin, + symbol: _selectedTicker, + isEmbedded: true, + ); + case 1: + return TechnicalTab( + isin: widget.isin, + symbol: _selectedTicker, + showDetailsOnly: true, + ); + case 2: + return SizedBox( + height: 500, + child: TradesTab(symbol: widget.isin), + ); + default: + return const SizedBox.shrink(); + } + }, + ), + ], + ), + ), + const SizedBox(height: 24), ], ), ), ); } } - -class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { - final TabBar _tabBar; - final Color _backgroundColor; - - _SliverAppBarDelegate(this._tabBar, this._backgroundColor); - - @override - double get minExtent => _tabBar.preferredSize.height; - - @override - double get maxExtent => _tabBar.preferredSize.height; - - @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { - return Container( - color: _backgroundColor, - child: _tabBar, - ); - } - - @override - bool shouldRebuild(_SliverAppBarDelegate oldDelegate) { - return false; - } -} diff --git a/FinlyticApp/lib/features/asset_detail/views/tabs/fundamentals_tab.dart b/FinlyticApp/lib/features/asset_detail/views/tabs/fundamentals_tab.dart index 3499da5..015df5c 100644 --- a/FinlyticApp/lib/features/asset_detail/views/tabs/fundamentals_tab.dart +++ b/FinlyticApp/lib/features/asset_detail/views/tabs/fundamentals_tab.dart @@ -1,26 +1,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../models/fundamental_data_model.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/glass_container.dart'; +import '../../../../core/widgets/shimmer_loading.dart'; import '../../../../core/widgets/status_badge.dart'; import '../../bloc/fundamentals/asset_fundamentals_bloc.dart'; import '../../bloc/fundamentals/asset_fundamentals_event.dart'; import '../../bloc/fundamentals/asset_fundamentals_state.dart'; +import '../../models/fundamental_data_model.dart'; import '../../utils/metric_explanations.dart'; class FundamentalsTab extends StatefulWidget { final String isin; final String? symbol; - const FundamentalsTab({super.key, this.symbol, required this.isin}); + final bool isEmbedded; + + const FundamentalsTab({ + super.key, + this.symbol, + this.isEmbedded = false, + required this.isin, + }); @override State createState() => _FundamentalsTabState(); } class _FundamentalsTabState extends State { - String _selectedPeriodType = 'Annual'; // 'Annual' or 'Quarterly' - String _selectedStatementType = 'Income'; // 'Income', 'Balance', 'CashFlow' + String _sym = '\$'; + String _curCode = 'USD'; @override void initState() { @@ -37,7 +45,7 @@ class _FundamentalsTabState extends State { return BlocBuilder( builder: (context, state) { if (state is AssetFundamentalsLoading) { - return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)); + return _buildFundamentalsShimmer(context); } if (state is AssetFundamentalsError) { @@ -64,11 +72,16 @@ class _FundamentalsTabState extends State { if (state is AssetFundamentalsLoaded) { final data = state.data; + if (data != null) { + _sym = _getCurrencySymbol(data.ticker); + _curCode = _getCurrencyCode(data.ticker); + } if (data == null) { return _buildEmptyState(); } return SingleChildScrollView( + physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -77,87 +90,11 @@ class _FundamentalsTabState extends State { _buildPriceTargetCard(data), const SizedBox(height: 20), - // 2. Valuation Multiples & Ratios - _buildSectionHeader('Bewertungskennzahlen & Multiples', Icons.analytics_outlined), - const SizedBox(height: 12), - GridView.count( - crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - _buildMetricCard('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)), - _buildMetricCard('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)), - _buildMetricCard('PEG Ratio', _fmtMultiple(data.pegRatio)), - _buildMetricCard('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)), - _buildMetricCard('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)), - _buildMetricCard('EV / EBITDA', _fmtMultiple(data.evToEbitda)), - _buildMetricCard('EV / Sales', _fmtMultiple(data.evToRevenue)), - _buildMetricCard('Enterprise Value', _formatNumber(data.enterpriseValue)), - _buildMetricCard('Marktkapitalisierung', _formatNumber(data.marketCapitalization)), - _buildMetricCard('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)), - _buildMetricCard('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)), - _buildMetricCard('Short Ratio', _fmtMultiple(data.shortRatio)), - ], - ), - const SizedBox(height: 24), + // 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends) + _buildCategoryPanels(data), + const SizedBox(height: 20), - // 3. Profitability & Financial Health Margins - _buildSectionHeader('Rentabilität & Finanzielle Gesundheit', Icons.account_balance_outlined), - const SizedBox(height: 12), - GridView.count( - crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - _buildMetricCard('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)), - _buildMetricCard('Operative Marge', _fmtPercent(data.operatingMargin)), - _buildMetricCard('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)), - _buildMetricCard('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)), - _buildMetricCard('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)), - _buildMetricCard('ROIC (Invested Capital)', _fmtPercent(data.returnOnInvestedCapital)), - _buildMetricCard('Verschuldungsgrad (D/E)', _fmtMultiple(data.debtToEquity)), - _buildMetricCard('Current Ratio', _fmtMultiple(data.currentRatio)), - _buildMetricCard('Quick Ratio', _fmtMultiple(data.quickRatio)), - _buildMetricCard('Zinsdeckungsgrad', _fmtMultiple(data.interestCoverage)), - ], - ), - const SizedBox(height: 24), - - // 4. Dividends & Ownership - _buildSectionHeader('Dividenden & Aktionärsstruktur', Icons.pie_chart_outline), - const SizedBox(height: 12), - GridView.count( - crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - _buildMetricCard('Dividendenrendite', _fmtPercent(data.dividendYield)), - _buildMetricCard('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)), - _buildMetricCard('Ex-Dividendentag', _fmtDate(data.exDividendDate)), - _buildMetricCard('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)), - _buildMetricCard('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)), - _buildMetricCard('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)), - _buildMetricCard('Short % of Float', _fmtPercent(data.shortPercentOfFloat)), - ], - ), - const SizedBox(height: 24), - - // 5. Financial Statements Section - _buildSectionHeader('Finanzberichte (Statements)', Icons.article_outlined), - const SizedBox(height: 12), - _buildStatementsSection(data), - const SizedBox(height: 24), - - // 6. Company Description & Detailed Executive Board + // 3. Company Description & Detailed Executive Board _buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined), const SizedBox(height: 12), _buildProfileSection(data), @@ -219,180 +156,95 @@ class _FundamentalsTabState extends State { ); } - Widget _buildStatementsSection(FundamentalDataModel data) { - // Filter statements by Jährlich / Quartal - final filteredStatements = data.financialStatements - .where((s) => s.periodType.toLowerCase() == _selectedPeriodType.toLowerCase()) - .toList(); + Widget _buildFundamentalsShimmer(BuildContext context) { + final isDesktop = MediaQuery.of(context).size.width >= 1050; + final isTablet = MediaQuery.of(context).size.width >= 680 && MediaQuery.of(context).size.width < 1050; - // Sort descending by date - filteredStatements.sort((a, b) => b.endDate.compareTo(a.endDate)); + Widget panelShimmer() { + return GlassContainer( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerLoading(width: 180, height: 18, borderRadius: 6), + const SizedBox(height: 12), + const Divider(color: Colors.white10, height: 1), + const SizedBox(height: 8), + for (int i = 0; i < 9; i++) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + ShimmerLoading(width: 100, height: 14, borderRadius: 4), + ShimmerLoading(width: 60, height: 14, borderRadius: 4), + ], + ), + ), + ], + ], + ), + ); + } - return GlassContainer( + return SingleChildScrollView( + physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Row containing switches - Row( - children: [ - // Period Toggle (Annual / Quarterly) - DropdownButton( - value: _selectedPeriodType, - dropdownColor: AppTheme.cardSurface, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), - underline: const SizedBox.shrink(), - icon: const Icon(Icons.arrow_drop_down, color: Colors.white), - items: const [ - DropdownMenuItem(value: 'Annual', child: Text('Jährlich (Annual)')), - DropdownMenuItem(value: 'Quarterly', child: Text('Quartal (Quarterly)')), - ], - onChanged: (val) { - if (val != null) { - setState(() => _selectedPeriodType = val); - } - }, - ), - const Spacer(), - // Statement Type Selector - Row( - children: [ - _buildStatementTabButton('GuV', 'Income'), - const SizedBox(width: 6), - _buildStatementTabButton('Bilanz', 'Balance'), - const SizedBox(width: 6), - _buildStatementTabButton('Cashflow', 'CashFlow'), - ], - ), - ], - ), - const SizedBox(height: 16), - const Divider(color: Colors.white10), - const SizedBox(height: 8), + // Price Target Card Shimmer + const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16), + const SizedBox(height: 20), - if (filteredStatements.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Center( - child: Text( - 'Keine Berichte für diesen Typ vorhanden.', - style: TextStyle(color: AppTheme.textMuted, fontStyle: FontStyle.italic), + // 3 Category Panels Shimmer + if (isDesktop) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: panelShimmer()), + const SizedBox(width: 14), + Expanded(child: panelShimmer()), + const SizedBox(width: 14), + Expanded(child: panelShimmer()), + ], + ) + else if (isTablet) + Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: panelShimmer()), + const SizedBox(width: 12), + Expanded(child: panelShimmer()), + ], ), - ), + const SizedBox(height: 12), + panelShimmer(), + ], ) else - SingleChildScrollView( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - child: Table( - defaultColumnWidth: const FixedColumnWidth(110), - columnWidths: const { - 0: FixedColumnWidth(180), // First column containing label is wider - }, - border: TableBorder( - horizontalInside: BorderSide(color: Colors.white, width: 0.5), - ), - children: _buildTableRows(filteredStatements), - ), + Column( + children: [ + panelShimmer(), + const SizedBox(height: 12), + panelShimmer(), + const SizedBox(height: 12), + panelShimmer(), + ], ), + + const SizedBox(height: 20), + // Profile Section Shimmer + const ShimmerLoading(width: 220, height: 20, borderRadius: 6), + const SizedBox(height: 12), + const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16), ], ), ); } - Widget _buildStatementTabButton(String label, String typeCode) { - final isSelected = _selectedStatementType == typeCode; - return InkWell( - onTap: () => setState(() => _selectedStatementType = typeCode), - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : Colors.white10, - ), - ), - child: Text( - label, - style: TextStyle( - color: isSelected ? AppTheme.primaryEmerald : Colors.white70, - fontSize: 12, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - ), - ), - ); - } - - List _buildTableRows(List statements) { - final List rows = []; - - // Header row containing Dates - rows.add( - TableRow( - children: [ - _buildTableCell('Kennzahl (in EUR)', isHeader: true), - ...statements.map((s) => _buildTableCell(_fmtDate(s.endDate), isHeader: true)), - ], - ), - ); - - if (_selectedStatementType == 'Income') { - rows.add(_buildDataRow('Umsatzerlöse', statements.map((s) => s.totalRevenue).toList())); - rows.add(_buildDataRow('Umsatzkosten', statements.map((s) => s.costOfRevenue).toList())); - rows.add(_buildDataRow('Bruttogewinn', statements.map((s) => s.grossProfit).toList())); - rows.add(_buildDataRow('Operative Aufwendungen', statements.map((s) => s.operatingExpenses).toList())); - rows.add(_buildDataRow('Operatives Ergebnis (EBIT)', statements.map((s) => s.operatingIncome).toList())); - rows.add(_buildDataRow('EBITDA', statements.map((s) => s.ebitda).toList())); - rows.add(_buildDataRow('Jahresüberschuss', statements.map((s) => s.netIncome).toList())); - rows.add(_buildDataRow('EPS (Basic)', statements.map((s) => s.epsBasic).toList(), isCurrency: true)); - rows.add(_buildDataRow('EPS (Diluted)', statements.map((s) => s.epsDiluted).toList(), isCurrency: true)); - } else if (_selectedStatementType == 'Balance') { - rows.add(_buildDataRow('Liquide Mittel', statements.map((s) => s.cashAndCashEquivalents).toList())); - rows.add(_buildDataRow('Forderungen', statements.map((s) => s.accountsReceivable).toList())); - rows.add(_buildDataRow('Vorräte', statements.map((s) => s.inventory).toList())); - rows.add(_buildDataRow('Umlaufvermögen (Current Assets)', statements.map((s) => s.totalCurrentAssets).toList())); - rows.add(_buildDataRow('Anlagevermögen (Non-Current)', statements.map((s) => s.totalNonCurrentAssets).toList())); - rows.add(_buildDataRow('Kurzfr. Verbindlichkeiten', statements.map((s) => s.currentLiabilities).toList())); - rows.add(_buildDataRow('Langfristige Schulden', statements.map((s) => s.longTermDebt).toList())); - rows.add(_buildDataRow('Gesamtverbindlichkeiten', statements.map((s) => s.totalLiabilities).toList())); - rows.add(_buildDataRow('Eigenkapital (Equity)', statements.map((s) => s.totalStockholdersEquity).toList())); - } else { - rows.add(_buildDataRow('Operativer Cashflow', statements.map((s) => s.operatingCashFlow).toList())); - rows.add(_buildDataRow('Investiver Cashflow', statements.map((s) => s.investingCashFlow).toList())); - rows.add(_buildDataRow('Investitionsausgaben (CapEx)', statements.map((s) => s.capitalExpenditures).toList())); - rows.add(_buildDataRow('Finanzierungs-Cashflow', statements.map((s) => s.financingCashFlow).toList())); - rows.add(_buildDataRow('Free Cashflow', statements.map((s) => s.freeCashFlow).toList())); - } - - return rows; - } - - TableRow _buildDataRow(String label, List values, {bool isCurrency = false}) { - return TableRow( - children: [ - _buildTableCell(label), - ...values.map((v) => _buildTableCell(isCurrency ? _fmtCurrency(v) : _formatNumber(v))), - ], - ); - } - - Widget _buildTableCell(String val, {bool isHeader = false}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), - child: Text( - val, - style: TextStyle( - color: isHeader ? AppTheme.accentCyan : Colors.white70, - fontWeight: isHeader ? FontWeight.bold : FontWeight.normal, - fontSize: 12, - ), - ), - ); - } - Widget _buildProfileSection(FundamentalDataModel data) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -532,43 +384,197 @@ class _FundamentalsTabState extends State { ); } - Widget _buildMetricCard(String label, String value) { + Widget _buildCategoryPanels(FundamentalDataModel data) { + final valuationItems = [ + _MetricRowItem('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)), + _MetricRowItem('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)), + _MetricRowItem('PEG Ratio', _fmtMultiple(data.pegRatio)), + _MetricRowItem('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)), + _MetricRowItem('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)), + _MetricRowItem('EV / EBITDA', _fmtMultiple(data.evToEbitda)), + _MetricRowItem('EV / Sales', _fmtMultiple(data.evToRevenue)), + _MetricRowItem('Enterprise Value', _formatNumber(data.enterpriseValue)), + _MetricRowItem('Marktkapitalisierung', _formatNumber(data.marketCapitalization)), + _MetricRowItem('Gewinn je Aktie (EPS)', _fmtCurrency(data.dilutedEps)), + _MetricRowItem('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)), + _MetricRowItem('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)), + ]; + + final profitabilityItems = [ + _MetricRowItem('Umsatzerlöse (Revenue)', _formatNumber(data.totalRevenue)), + _MetricRowItem('Umsatzwachstum (YoY)', _fmtPercent(data.revenueGrowthYoY)), + _MetricRowItem('Bruttogewinn', _formatNumber(data.grossProfit)), + _MetricRowItem('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)), + _MetricRowItem('EBITDA', _formatNumber(data.ebitda)), + _MetricRowItem('Operative Marge', _fmtPercent(data.operatingMargin)), + _MetricRowItem('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)), + _MetricRowItem('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)), + _MetricRowItem('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)), + _MetricRowItem('Verschuldungsgrad (D/E)', _fmtDebtToEquity(data.debtToEquity)), + _MetricRowItem('Current Ratio', _fmtMultiple(data.currentRatio)), + _MetricRowItem('Liquide Mittel (Cash)', _formatNumber(data.totalCash)), + _MetricRowItem('Gesamtverschuldung (Debt)', _formatNumber(data.totalDebt)), + _MetricRowItem('Operativer Cashflow', _formatNumber(data.operatingCashFlow)), + _MetricRowItem('Free Cashflow', _formatNumber(data.freeCashFlow)), + ]; + + final dividendItems = [ + _MetricRowItem('Dividendenrendite', _fmtPercent(data.dividendYield)), + _MetricRowItem('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)), + _MetricRowItem('Ex-Dividendentag', _fmtDate(data.exDividendDate)), + _MetricRowItem('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)), + _MetricRowItem('Konsens-Rating', data.consensusRating != null ? data.consensusRating!.toUpperCase() : 'N/A'), + _MetricRowItem('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)), + _MetricRowItem('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)), + _MetricRowItem('Short % of Float', _fmtPercent(data.shortPercentOfFloat)), + ]; + + final panel1 = _buildCategoryPanel( + title: 'Bewertungskennzahlen & Multiples', + icon: Icons.analytics_outlined, + items: valuationItems, + ); + + final panel2 = _buildCategoryPanel( + title: 'Rentabilität & Finanzen', + icon: Icons.account_balance_outlined, + items: profitabilityItems, + ); + + final panel3 = _buildCategoryPanel( + title: 'Dividenden & Termine', + icon: Icons.pie_chart_outline, + items: dividendItems, + ); + + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 1050) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: panel1), + const SizedBox(width: 14), + Expanded(child: panel2), + const SizedBox(width: 14), + Expanded(child: panel3), + ], + ); + } else if (constraints.maxWidth >= 680) { + return Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: panel1), + const SizedBox(width: 12), + Expanded(child: panel2), + ], + ), + const SizedBox(height: 12), + panel3, + ], + ); + } else { + return Column( + children: [ + panel1, + const SizedBox(height: 12), + panel2, + const SizedBox(height: 12), + panel3, + ], + ); + } + }, + ); + } + + Widget _buildCategoryPanel({ + required String title, + required IconData icon, + required List<_MetricRowItem> items, + }) { + return GlassContainer( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppTheme.primaryEmerald.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Icon(icon, color: AppTheme.primaryEmerald, size: 16), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + const Divider(color: Colors.white10, height: 1), + const SizedBox(height: 4), + ...items.asMap().entries.map((entry) { + final idx = entry.key; + final item = entry.value; + final isEven = idx % 2 == 0; + return _buildMetricListRow(item.label, item.value, isEven: isEven, valueColor: item.valueColor); + }), + ], + ), + ); + } + + Widget _buildMetricListRow(String label, String value, {bool isEven = false, Color? valueColor}) { return InkWell( onTap: () => MetricExplanations.show(context, label), - borderRadius: BorderRadius.circular(10), - child: GlassContainer( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: Text( - label, - style: TextStyle(color: AppTheme.textMuted, fontSize: 11), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + Text( + label, + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), ), const SizedBox(width: 4), - Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted), + Icon(Icons.info_outline, size: 11, color: AppTheme.textMuted.withValues(alpha: 0.6)), ], ), - const SizedBox(height: 4), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - value, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14), - ), + const SizedBox(width: 8), + Flexible( + child: Text( + value, + style: TextStyle( + color: valueColor ?? (value == 'N/A' ? AppTheme.textMuted : Colors.white), + fontWeight: FontWeight.w600, + fontSize: 12, ), + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ], @@ -583,18 +589,38 @@ class _FundamentalsTabState extends State { return n != null ? '${n.toStringAsFixed(2)}x' : 'N/A'; } + String _fmtDays(dynamic val) { + if (val == null) return 'N/A'; + final n = (val is num) ? val.toDouble() : double.tryParse(val.toString()); + return n != null ? '${n.toStringAsFixed(1)} Tage' : 'N/A'; + } + + String _fmtDebtToEquity(dynamic val) { + if (val == null) return 'N/A'; + final n = (val is num) ? val.toDouble() : double.tryParse(val.toString()); + if (n == null) return 'N/A'; + // Yahoo liefert D/E als Prozentwert (z. B. 145.23 = 145.23% oder Faktor 1.45x) + if (n > 5) { + return '${(n / 100).toStringAsFixed(2)}x (${n.toStringAsFixed(1)} %)'; + } + return '${n.toStringAsFixed(2)}x (${(n * 100).toStringAsFixed(1)} %)'; + } + String _fmtPercent(dynamic val) { if (val == null) return 'N/A'; final n = (val is num) ? val.toDouble() : double.tryParse(val.toString()); if (n == null) return 'N/A'; - final p = (n > 0 && n <= 1) ? n * 100 : n; - return '${p.toStringAsFixed(2)}%'; + // Yahoo liefert Margen/Renditen als Dezimalzahl (z. B. 0.25 = 25%, 1.2 = 120%) + // Wenn |n| <= 2.5 ist, handelt es sich um eine Dezimalquote -> mit 100 multiplizieren + final p = n.abs() <= 2.5 ? n * 100 : n; + return '${p.toStringAsFixed(2)} %'; } String _fmtCurrency(dynamic val) { if (val == null) return 'N/A'; final n = (val is num) ? val.toDouble() : double.tryParse(val.toString()); - return n != null ? '€${n.toStringAsFixed(2)}' : 'N/A'; + if (n == null || n == 0) return 'N/A'; + return '$_sym${n.toStringAsFixed(2)}'; } String _fmtDate(dynamic val) { @@ -610,18 +636,62 @@ class _FundamentalsTabState extends State { final isNegative = n < 0; final absVal = n.abs(); - final prefix = isNegative ? '-€' : '€'; + final prefix = isNegative ? '-$_sym' : _sym; if (absVal >= 1e12) { - return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bil.'; + return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bio.'; } else if (absVal >= 1e9) { return '$prefix${(absVal / 1e9).toStringAsFixed(2)} Mrd.'; } else if (absVal >= 1e6) { return '$prefix${(absVal / 1e6).toStringAsFixed(2)} Mio.'; } else if (absVal >= 1e3) { - return '$prefix${(absVal / 1e3).toStringAsFixed(2)} Tsd.'; + return '$prefix${(absVal / 1e3).toStringAsFixed(1)} Tsd.'; } else { return '$prefix${absVal.toStringAsFixed(2)}'; } } + + /// Leitet das Währungssymbol vom Ticker-Suffix ab. + String _getCurrencySymbol(String? ticker) { + if (ticker == null || ticker.isEmpty) return '\$'; + final t = ticker.toUpperCase(); + if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.STU') || + t.endsWith('.MU') || t.endsWith('.HM') || t.endsWith('.DU') || + t.endsWith('.BE') || t.endsWith('.SG') || + t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI') || + t.endsWith('.MC')) return '€'; + if (t.endsWith('.L')) return '£'; + if (t.endsWith('.SW')) return 'CHF '; + if (t.endsWith('.TO')) return 'CA\$'; + if (t.endsWith('.AX')) return 'A\$'; + if (t.endsWith('.T')) return '¥'; + if (t.endsWith('.HK')) return 'HK\$'; + return '\$'; + } + + /// Leitet den Währungscode vom Ticker-Suffix ab. + String _getCurrencyCode(String? ticker) { + if (ticker == null || ticker.isEmpty) return 'USD'; + final t = ticker.toUpperCase(); + if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.STU') || + t.endsWith('.MU') || t.endsWith('.HM') || t.endsWith('.DU') || + t.endsWith('.BE') || t.endsWith('.SG') || + t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI') || + t.endsWith('.MC')) return 'EUR'; + if (t.endsWith('.L')) return 'GBP'; + if (t.endsWith('.SW')) return 'CHF'; + if (t.endsWith('.TO')) return 'CAD'; + if (t.endsWith('.AX')) return 'AUD'; + if (t.endsWith('.T')) return 'JPY'; + if (t.endsWith('.HK')) return 'HKD'; + return 'USD'; + } +} + +class _MetricRowItem { + final String label; + final String value; + final Color? valueColor; + + const _MetricRowItem(this.label, this.value, {this.valueColor}); } diff --git a/FinlyticApp/lib/features/asset_detail/views/tabs/technical_tab.dart b/FinlyticApp/lib/features/asset_detail/views/tabs/technical_tab.dart index 50c6a68..429e6f7 100644 --- a/FinlyticApp/lib/features/asset_detail/views/tabs/technical_tab.dart +++ b/FinlyticApp/lib/features/asset_detail/views/tabs/technical_tab.dart @@ -3,6 +3,7 @@ import 'package:intl/intl.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/glass_container.dart'; +import '../../../../core/widgets/shimmer_loading.dart'; import '../../../../core/widgets/status_badge.dart'; import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_event.dart'; @@ -15,11 +16,17 @@ class TechnicalTab extends StatefulWidget { final String isin; final String? symbol; final bool isDesktopLeftPanel; + final bool showChartOnly; + final bool showDetailsOnly; + final double chartHeight; const TechnicalTab({ super.key, this.symbol, this.isDesktopLeftPanel = false, + this.showChartOnly = false, + this.showDetailsOnly = false, + this.chartHeight = 420, required this.isin, }); @@ -53,8 +60,7 @@ class _TechnicalTabState extends State { return BlocBuilder( builder: (context, state) { if (state is AssetTechnicalLoading) { - return Center( - child: CircularProgressIndicator(color: AppTheme.primaryEmerald)); + return _buildTechnicalShimmer(context); } if (state is AssetTechnicalError) { @@ -132,166 +138,186 @@ class _TechnicalTabState extends State { if (!_disabledPatternIndices.contains(i)) patterns[i] ]; + final chartRibbon = GlassContainer( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildIndicatorChip( + 'EMA (20)', + _showEma, + (v) => setState(() => _showEma = v), + Colors.blueAccent), + const SizedBox(width: 6), + _buildIndicatorChip( + 'SMA (50)', + _showSma50, + (v) => setState(() => _showSma50 = v), + Colors.orangeAccent), + const SizedBox(width: 6), + _buildIndicatorChip( + 'SMA (200)', + _showSma200, + (v) => setState(() => _showSma200 = v), + Colors.redAccent), + const SizedBox(width: 6), + _buildIndicatorChip( + 'Supertrend', + _showSupertrend, + (v) => setState(() => _showSupertrend = v), + AppTheme.primaryEmerald), + const SizedBox(width: 6), + _buildIndicatorChip( + 'Alle Muster', + _showPatterns, + (v) => setState(() => _showPatterns = v), + Colors.amberAccent), + const SizedBox(width: 6), + _buildIndicatorChip( + 'Signale', + _showSignals, + (v) => setState(() => _showSignals = v), + AppTheme.accentCyan), + ], + ), + ), + ); + + final chartWidget = SizedBox( + height: widget.chartHeight, + width: double.infinity, + child: CandlestickChart( + candles: candles, + patterns: activePatterns, + signals: signals, + indicators: indicators, + showPatterns: _showPatterns, + showEma: _showEma, + showSma50: _showSma50, + showSma200: _showSma200, + showSignals: _showSignals, + showSupertrend: _showSupertrend, + ), + ); + + if (widget.showChartOnly) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + chartRibbon, + const SizedBox(height: 8), + chartWidget, + ], + ); + } + + final detailsSection = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.architecture_outlined, + color: AppTheme.primaryEmerald, size: 20), + const SizedBox(width: 8), + const Text('Erkannte Chart-Muster & Signale', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white)), + ], + ), + if (patterns.isNotEmpty) + TextButton.icon( + onPressed: () { + setState(() { + if (_disabledPatternIndices.length == + patterns.length) { + _disabledPatternIndices.clear(); + } else { + _disabledPatternIndices.addAll( + List.generate( + patterns.length, (i) => i)); + } + }); + }, + icon: Icon( + _disabledPatternIndices.isEmpty + ? Icons.deselect + : Icons.select_all, + size: 16, + color: Colors.amberAccent), + label: Text( + _disabledPatternIndices.isEmpty + ? 'Alle abwählen' + : 'Alle anwählen', + style: const TextStyle( + color: Colors.amberAccent, fontSize: 12)), + ), + ], + ), + const SizedBox(height: 12), + if (patterns.isEmpty && signals.isEmpty) + GlassContainer( + padding: const EdgeInsets.all(16), + child: Center( + child: Text( + 'Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.', + style: TextStyle( + color: AppTheme.textMuted, fontSize: 12)), + ), + ) + else ...[ + if (patterns.isNotEmpty) ...[ + Text( + 'Formationen & Trendlinien (Mit Checkbox im Chart schalten):', + style: TextStyle( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w600, + fontSize: 13)), + const SizedBox(height: 6), + ...List.generate( + patterns.length, + (index) => + _buildPatternCard(patterns[index], index)), + const SizedBox(height: 12), + ], + if (signals.isNotEmpty) ...[ + Text('Strategie-Signale:', + style: TextStyle( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w600, + fontSize: 13)), + const SizedBox(height: 6), + ...signals.map((s) => _buildSignalCard(s)), + ], + ], + ], + ), + ); + + if (widget.showDetailsOnly) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: detailsSection, + ); + } + return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Glassmorphic Indicator & Pattern Control Ribbon - GlassContainer( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildIndicatorChip( - 'EMA (20)', - _showEma, - (v) => setState(() => _showEma = v), - Colors.blueAccent), - const SizedBox(width: 6), - _buildIndicatorChip( - 'SMA (50)', - _showSma50, - (v) => setState(() => _showSma50 = v), - Colors.orangeAccent), - const SizedBox(width: 6), - _buildIndicatorChip( - 'SMA (200)', - _showSma200, - (v) => setState(() => _showSma200 = v), - Colors.redAccent), - const SizedBox(width: 6), - _buildIndicatorChip( - 'Supertrend', - _showSupertrend, - (v) => setState(() => _showSupertrend = v), - AppTheme.primaryEmerald), - const SizedBox(width: 6), - _buildIndicatorChip( - 'Alle Muster', - _showPatterns, - (v) => setState(() => _showPatterns = v), - Colors.amberAccent), - const SizedBox(width: 6), - _buildIndicatorChip( - 'Signale', - _showSignals, - (v) => setState(() => _showSignals = v), - AppTheme.accentCyan), - ], - ), - ), - ), + chartRibbon, const SizedBox(height: 8), - - // Interactive Candlestick Chart - SizedBox( - height: 380, - child: CandlestickChart( - candles: candles, - patterns: activePatterns, - signals: signals, - indicators: indicators, - showPatterns: _showPatterns, - showEma: _showEma, - showSma50: _showSma50, - showSma200: _showSma200, - showSignals: _showSignals, - showSupertrend: _showSupertrend, - ), - ), + chartWidget, const SizedBox(height: 16), - - // Dedicated Chart Patterns & Signal Description List Section - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.architecture_outlined, - color: AppTheme.primaryEmerald, size: 20), - const SizedBox(width: 8), - const Text('Erkannte Chart-Muster & Signale', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white)), - ], - ), - if (patterns.isNotEmpty) - TextButton.icon( - onPressed: () { - setState(() { - if (_disabledPatternIndices.length == - patterns.length) { - _disabledPatternIndices.clear(); - } else { - _disabledPatternIndices.addAll( - List.generate( - patterns.length, (i) => i)); - } - }); - }, - icon: Icon( - _disabledPatternIndices.isEmpty - ? Icons.deselect - : Icons.select_all, - size: 16, - color: Colors.amberAccent), - label: Text( - _disabledPatternIndices.isEmpty - ? 'Alle abwählen' - : 'Alle anwählen', - style: const TextStyle( - color: Colors.amberAccent, fontSize: 12)), - ), - ], - ), - const SizedBox(height: 12), - if (patterns.isEmpty && signals.isEmpty) - GlassContainer( - padding: const EdgeInsets.all(16), - child: Center( - child: Text( - 'Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.', - style: TextStyle( - color: AppTheme.textMuted, fontSize: 12)), - ), - ) - else ...[ - if (patterns.isNotEmpty) ...[ - Text( - 'Formationen & Trendlinien (Mit Checkbox im Chart schalten):', - style: TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - fontSize: 13)), - const SizedBox(height: 6), - ...List.generate( - patterns.length, - (index) => - _buildPatternCard(patterns[index], index)), - const SizedBox(height: 12), - ], - if (signals.isNotEmpty) ...[ - Text('Strategie-Signale:', - style: TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - fontSize: 13)), - const SizedBox(height: 6), - ...signals.map((s) => _buildSignalCard(s)), - ], - ], - ], - ), - ), + detailsSection, const SizedBox(height: 16), ], ), @@ -506,4 +532,54 @@ class _TechnicalTabState extends State { ], ); } + + Widget _buildTechnicalShimmer(BuildContext context) { + if (widget.showChartOnly) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12), + const SizedBox(height: 8), + ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16), + ], + ); + } + + if (widget.showDetailsOnly) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerLoading(width: 240, height: 20, borderRadius: 6), + const SizedBox(height: 14), + for (int i = 0; i < 4; i++) ...[ + const ShimmerLoading(width: double.infinity, height: 68, borderRadius: 12), + const SizedBox(height: 10), + ], + ], + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12), + const SizedBox(height: 8), + ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16), + const SizedBox(height: 16), + const ShimmerLoading(width: 240, height: 20, borderRadius: 6), + const SizedBox(height: 14), + for (int i = 0; i < 3; i++) ...[ + const ShimmerLoading(width: double.infinity, height: 68, borderRadius: 12), + const SizedBox(height: 10), + ], + ], + ), + ); + } } diff --git a/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart b/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart index a526694..59d72a4 100644 --- a/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart +++ b/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart @@ -2,6 +2,7 @@ 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/shimmer_loading.dart'; import '../../../../core/widgets/status_badge.dart'; import 'package:finlytic_app/features/trades/models/trade_model.dart'; @@ -553,7 +554,7 @@ class _TradesTabState extends State { const SizedBox(height: 20), if (state is AssetTradesLoading) - Center(child: Padding(padding: const EdgeInsets.all(32), child: CircularProgressIndicator(color: AppTheme.primaryEmerald))) + _buildTradesShimmer(context) else if (state is AssetTradesError) GlassContainer( padding: const EdgeInsets.all(16), @@ -583,6 +584,20 @@ class _TradesTabState extends State { ); } + Widget _buildTradesShimmer(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerLoading(width: 260, height: 20, borderRadius: 6), + const SizedBox(height: 12), + for (int i = 0; i < 3; i++) ...[ + const ShimmerLoading(width: double.infinity, height: 105, borderRadius: 14), + const SizedBox(height: 12), + ], + ], + ); + } + Widget _buildTradeList(String title, List trades) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -649,7 +664,7 @@ class _TradesTabState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header Row: Side, Status, Instrument, Action Buttons + // Header Row: Side, Status, Instrument, Action Buttons cv Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart b/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart index ca8d013..3313662 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart @@ -168,10 +168,28 @@ class _CandlestickChartState extends State { return Listener( onPointerSignal: (pointerSignal) { if (pointerSignal is PointerScrollEvent) { - setState(() { - final double zoomFactor = pointerSignal.scrollDelta.dy > 0 ? 0.9 : 1.1; - _scale = (_scale * zoomFactor).clamp(0.2, 5.0); - }); + GestureBinding.instance.pointerSignalResolver.register( + pointerSignal, + (event) { + if (event is PointerScrollEvent) { + setState(() { + final double localX = event.localPosition.dx; + final double zoomFactor = event.scrollDelta.dy > 0 ? 0.9 : 1.1; + final double newScale = (_scale * zoomFactor).clamp(0.2, 5.0); + final double scaleRatio = newScale / _scale; + + // Zoom centered on cursor + _panOffset = localX - (localX - _panOffset) * scaleRatio; + _scale = newScale; + + final double updatedCandleSpace = (baseWidth + spacing) * _scale; + final double updatedContentWidth = (widget.candles.length + 15) * updatedCandleSpace; + final double newMinOffset = constraints.maxWidth - updatedContentWidth - 60.0; + _panOffset = _panOffset.clamp(newMinOffset < maxOffset ? newMinOffset : maxOffset, maxOffset); + }); + } + }, + ); } }, child: GestureDetector( diff --git a/FinlyticApp/lib/features/asset_detail/widgets/header/asset_hero_header.dart b/FinlyticApp/lib/features/asset_detail/widgets/header/asset_hero_header.dart index 8271026..487ff35 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/header/asset_hero_header.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/header/asset_hero_header.dart @@ -5,6 +5,8 @@ import '../../../../core/widgets/asset_logo_widget.dart'; import '../../../../shared/widgets/favorite_star_button.dart'; import '../../bloc/header/asset_header_bloc.dart'; import '../../bloc/header/asset_header_state.dart'; +import '../../bloc/technical/asset_technical_bloc.dart'; +import '../../bloc/technical/asset_technical_state.dart'; import '../../models/asset_model.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -146,45 +148,66 @@ class AssetHeroHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AKTUELLER PREIS', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: theme.primaryColor, - letterSpacing: 1.5, - ), - ), - const SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.end, + BlocBuilder( + builder: (context, taState) { + double? livePrice = price; + String liveCurrency = selectedOption.tradingCurrency.isNotEmpty + ? selectedOption.tradingCurrency + : currency; + + if (taState is AssetTechnicalLoaded && taState.data != null) { + if (taState.data!.candles.isNotEmpty) { + final lastClose = taState.data!.candles.last.close; + if (lastClose > 0) { + livePrice = lastClose; + } + } + if (taState.data!.currency.isNotEmpty) { + liveCurrency = taState.data!.currency; + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SelectableText( - price != null && price > 0 ? price.toStringAsFixed(2) : '---', + Text( + 'AKTUELLER PREIS', style: TextStyle( - fontSize: 32, + fontSize: 11, fontWeight: FontWeight.bold, - color: theme.textPrimary, + color: theme.primaryColor, + letterSpacing: 1.5, ), ), - const SizedBox(width: 6), - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - selectedOption.tradingCurrency.isNotEmpty ? selectedOption.tradingCurrency : currency, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: theme.primaryColor, + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SelectableText( + livePrice != null && livePrice > 0 ? livePrice.toStringAsFixed(2) : '---', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: theme.textPrimary, + ), ), - ), + const SizedBox(width: 6), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + liveCurrency, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.primaryColor, + ), + ), + ), + ], ), ], - ), - ], + ); + }, ), // Interactive Ticker & Exchange Selector Dropdown PopupMenuButton( diff --git a/FinlyticApp/lib/features/news/models/matched_asset_model.dart b/FinlyticApp/lib/features/news/models/matched_asset_model.dart new file mode 100644 index 0000000..e9965d9 --- /dev/null +++ b/FinlyticApp/lib/features/news/models/matched_asset_model.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +class MatchedAssetModel extends Equatable { + final String isin; + final String symbol; + final String name; + + const MatchedAssetModel({ + required this.isin, + required this.symbol, + required this.name, + }); + + factory MatchedAssetModel.fromJson(Map json) { + return MatchedAssetModel( + isin: (json['isin'] ?? json['Isin'])?.toString() ?? '', + symbol: (json['symbol'] ?? json['Symbol'] ?? json['ticker'] ?? json['Ticker'])?.toString() ?? '', + name: (json['name'] ?? json['Name'] ?? json['companyName'] ?? json['CompanyName'])?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'isin': isin, + 'symbol': symbol, + 'name': name, + }; + } + + @override + List get props => [isin, symbol, name]; +} diff --git a/FinlyticApp/lib/features/news/models/news_article_model.dart b/FinlyticApp/lib/features/news/models/news_article_model.dart index f1ca987..99a246d 100644 --- a/FinlyticApp/lib/features/news/models/news_article_model.dart +++ b/FinlyticApp/lib/features/news/models/news_article_model.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'finbert_result_model.dart'; +import 'matched_asset_model.dart'; class NewsArticleModel extends Equatable { final String id; @@ -17,6 +18,7 @@ class NewsArticleModel extends Equatable { final double sentimentScore; final double confidence; final FinbertResultModel? finbertResult; + final List matchedAssets; const NewsArticleModel({ required this.id, @@ -32,9 +34,16 @@ class NewsArticleModel extends Equatable { required this.sentimentScore, required this.confidence, this.finbertResult, + this.matchedAssets = const [], }); factory NewsArticleModel.fromJson(Map json) { + List assets = []; + final mList = json['matchedAssets'] ?? json['MatchedAssets']; + if (mList != null && mList is List) { + assets = mList.map((e) => MatchedAssetModel.fromJson(e as Map)).toList(); + } + return NewsArticleModel( id: json['id']?.toString() ?? json['Id']?.toString() ?? '', title: json['title']?.toString() ?? json['Title']?.toString() ?? 'No Title', @@ -52,6 +61,7 @@ class NewsArticleModel extends Equatable { finbertResult: (json['finbertResult'] != null || json['FinbertResult'] != null) ? FinbertResultModel.fromJson(json['finbertResult'] ?? json['FinbertResult']) : null, + matchedAssets: assets, ); } diff --git a/FinlyticApp/lib/features/trades/models/trade_acceptance_dto.dart b/FinlyticApp/lib/features/trades/models/trade_acceptance_dto.dart index 75c8813..d15057f 100644 --- a/FinlyticApp/lib/features/trades/models/trade_acceptance_dto.dart +++ b/FinlyticApp/lib/features/trades/models/trade_acceptance_dto.dart @@ -19,6 +19,7 @@ class TradeAcceptanceDto { final double? stopLoss; final double? takeProfit; final String? instrumentType; + final String? derivativeIsin; final String? timeframe; final String? reasoning; @@ -42,6 +43,7 @@ class TradeAcceptanceDto { this.stopLoss, this.takeProfit, this.instrumentType, + this.derivativeIsin, this.timeframe, this.reasoning, }); @@ -67,6 +69,7 @@ class TradeAcceptanceDto { 'stopLoss': stopLoss, 'takeProfit': takeProfit, 'instrumentType': instrumentType, + 'derivativeIsin': derivativeIsin, 'timeframe': timeframe, 'reasoning': reasoning, }; diff --git a/FinlyticApp/lib/features/trades/models/trade_model.dart b/FinlyticApp/lib/features/trades/models/trade_model.dart index b953571..5c64af4 100644 --- a/FinlyticApp/lib/features/trades/models/trade_model.dart +++ b/FinlyticApp/lib/features/trades/models/trade_model.dart @@ -27,6 +27,7 @@ class TradeModel extends Equatable { final double winRate; final String timeframe; final String instrumentType; + final String derivativeIsin; final DateTime? createdAt; final String riskTolerance; @@ -67,6 +68,7 @@ class TradeModel extends Equatable { this.winRate = 50.0, this.timeframe = '1D', this.instrumentType = 'Stock', + this.derivativeIsin = '', this.createdAt, this.riskTolerance = 'Moderate', this.vixValue = 0.0, @@ -92,21 +94,45 @@ class TradeModel extends Equatable { } double get calculatedPnlAbs { - if (pnlAbsolute != 0) return pnlAbsolute; + if (isClosed && pnlAbsolute != 0) return pnlAbsolute; + final curr = currentPrice; + if (curr <= 0) return pnlAbsolute; final entry = actualEntryPrice > 0 ? actualEntryPrice : entryPrice; - final curr = effectiveCurrentPrice; if (entry <= 0) return 0.0; final isShort = signalType == 'SELL' || signalType == 'SHORT'; final rawMove = isShort ? ((entry - curr) / entry) : ((curr - entry) / entry); - final posSize = positionSize > 0 ? positionSize : entry; + final posSize = positionSize > 0 ? positionSize : (quantity > 0 ? quantity * entry : entry); final lev = leverageUsed > 0 ? leverageUsed : 1.0; - return (rawMove * posSize * lev); + final fees = entryFee + exitFee; + return (rawMove * posSize * lev) - fees; } double get calculatedPnlPct { - if (pnlPercent != 0) return pnlPercent; + if (isClosed && pnlPercent != 0) return pnlPercent; final pnlAbs = calculatedPnlAbs; - final posSize = positionSize > 0 ? positionSize : (actualEntryPrice > 0 ? actualEntryPrice : entryPrice); + final posSize = positionSize > 0 ? positionSize : (actualEntryPrice > 0 ? actualEntryPrice : (entryPrice > 0 ? entryPrice : 1.0)); + if (posSize <= 0) return 0.0; + return (pnlAbs / posSize) * 100.0; + } + + double calculateLivePnlAbs(double livePrice) { + if (isClosed && pnlAbsolute != 0) return pnlAbsolute; + final curr = livePrice > 0 ? livePrice : currentPrice; + if (curr <= 0) return pnlAbsolute; + final entry = actualEntryPrice > 0 ? actualEntryPrice : entryPrice; + if (entry <= 0) return 0.0; + final isShort = signalType == 'SELL' || signalType == 'SHORT'; + final rawMove = isShort ? ((entry - curr) / entry) : ((curr - entry) / entry); + final posSize = positionSize > 0 ? positionSize : (quantity > 0 ? quantity * entry : entry); + final lev = leverageUsed > 0 ? leverageUsed : 1.0; + final fees = entryFee + exitFee; + return (rawMove * posSize * lev) - fees; + } + + double calculateLivePnlPct(double livePrice) { + if (isClosed && pnlPercent != 0) return pnlPercent; + final pnlAbs = calculateLivePnlAbs(livePrice); + final posSize = positionSize > 0 ? positionSize : (actualEntryPrice > 0 ? actualEntryPrice : (entryPrice > 0 ? entryPrice : 1.0)); if (posSize <= 0) return 0.0; return (pnlAbs / posSize) * 100.0; } @@ -161,6 +187,7 @@ class TradeModel extends Equatable { winRate: parseDbl(json['winRate'] ?? json['WinRate']), timeframe: (json['timeframe'] ?? json['Timeframe'])?.toString() ?? '1D', instrumentType: (json['instrumentType'] ?? json['InstrumentType'])?.toString() ?? 'Stock', + derivativeIsin: (json['derivativeIsin'] ?? json['DerivativeIsin'] ?? json['knockoutIsin'] ?? json['KnockoutIsin'])?.toString() ?? '', createdAt: dt, riskTolerance: (json['riskTolerance'] ?? json['RiskTolerance'])?.toString() ?? 'Moderate', vixValue: parseDbl(json['vixValue'] ?? json['VixValue']), @@ -205,6 +232,7 @@ class TradeModel extends Equatable { 'winRate': winRate, 'timeframe': timeframe, 'instrumentType': instrumentType, + 'derivativeIsin': derivativeIsin, 'createdAt': createdAt?.toIso8601String(), 'riskTolerance': riskTolerance, 'vixValue': vixValue, diff --git a/FinlyticApp/lib/features/trades/repositories/trade_repository.dart b/FinlyticApp/lib/features/trades/repositories/trade_repository.dart index 7b8e4a1..7af3e4b 100644 --- a/FinlyticApp/lib/features/trades/repositories/trade_repository.dart +++ b/FinlyticApp/lib/features/trades/repositories/trade_repository.dart @@ -10,7 +10,9 @@ class TradeRepository { Future> fetchTrades({String? isin, String? status}) async { try { - final queryParams = {}; + final queryParams = { + '_t': DateTime.now().millisecondsSinceEpoch, + }; if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin; if (status != null && status.isNotEmpty) queryParams['status'] = status; diff --git a/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart b/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart index b30bcc7..5b7db04 100644 --- a/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart +++ b/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart @@ -46,7 +46,7 @@ class _TradesFeedScreenContent extends StatefulWidget { } class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { - String _selectedFilter = 'Alle'; // 'Alle', 'Offen', 'Vorschläge', 'Geschlossen' + String _selectedFilter = 'Offen'; // 'Alle', 'Offen', 'Vorschläge', 'Geschlossen' String _searchQuery = ''; final TextEditingController _searchCtrl = TextEditingController(); @@ -165,7 +165,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { final rejectedTrades = allTrades.where((t) => t.isRejected).toList(); // Performance Header Calculations - final totalOpenPnlAbs = activeTrades.fold(0, (sum, t) => sum + t.pnlAbsolute); + final totalOpenPnlAbs = activeTrades.fold(0, (sum, t) => sum + t.calculatedPnlAbs); final isPnlPos = totalOpenPnlAbs >= 0; final winRatePct = allTrades.isNotEmpty ? (allTrades.where((t) => t.pnlAbsolute >= 0).length / allTrades.length * 100) diff --git a/FinlyticApp/lib/features/trades/widgets/trade_card.dart b/FinlyticApp/lib/features/trades/widgets/trade_card.dart index af8d703..16eadb1 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_card.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_card.dart @@ -1,6 +1,9 @@ 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 '../../favorites/cubit/favorites_cubit.dart'; +import '../../favorites/models/favorite_asset_model.dart'; import '../models/trade_model.dart'; import 'trade_detail_modal.dart'; @@ -26,23 +29,34 @@ class TradeCard extends StatelessWidget { final isActive = trade.isActive; final isClosed = trade.isClosed; - final pnlAbs = trade.calculatedPnlAbs; - final pnlPct = trade.calculatedPnlPct; - final isPnlPos = pnlAbs >= 0; - final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed; + return BlocBuilder( + builder: (context, favState) { + double livePrice = 0.0; + final keyUpper = (trade.isin.isNotEmpty ? trade.isin : trade.symbol).toUpperCase(); + final match = favState.favoriteDetails.firstWhere( + (f) => f.isin.toUpperCase() == keyUpper || f.symbol.toUpperCase() == keyUpper, + orElse: () => const FavoriteAssetModel(isin: '', symbol: '', name: '', currentPrice: 0.0, change24h: 0.0), + ); + if (match.currentPrice > 0) { + livePrice = match.currentPrice; + } - final currPrice = trade.effectiveCurrentPrice; + final pnlAbs = livePrice > 0 ? trade.calculateLivePnlAbs(livePrice) : trade.calculatedPnlAbs; + final pnlPct = livePrice > 0 ? trade.calculateLivePnlPct(livePrice) : trade.calculatedPnlPct; + final isPnlPos = pnlAbs >= 0; + 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), + 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: [ @@ -187,7 +201,7 @@ class TradeCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${trade.instrumentType.isNotEmpty ? trade.instrumentType : "Stock"} • ${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(0)}x Hebel" : ""}', + '${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), ), @@ -241,14 +255,16 @@ class TradeCard extends StatelessWidget { ), ), ], - ], - ), - ], - ), - ], + ], + ), + ], + ), + ], + ), ), - ), ); + }, +); } Widget _priceItem(String label, String val, Color valColor) { diff --git a/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart b/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart index 910a034..b458d22 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart @@ -287,6 +287,7 @@ class TradeDetailModal extends StatelessWidget { child: Column( children: [ _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)} €'), diff --git a/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart b/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart index f58e003..9bc16f2 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart @@ -1,6 +1,8 @@ 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'; @@ -8,381 +10,523 @@ class TradeExecutionDialog { static const double _defaultPositionSize = 1000.0; static const double _defaultLeverage = 1.0; + static const List _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')) { + return 'KnockOut'; + } + if (clean.contains('option')) { + return 'Option'; + } + if (clean.contains('cfd')) { + return 'CFD'; + } + if (clean.contains('crypto') || clean.contains('krypto')) { + return 'Crypto'; + } + if (clean.contains('stock') || clean.contains('aktie') || clean.contains('etf')) { + return 'Stock'; + } + return 'KnockOut'; // Fallback + } + 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 * initLev) / initEntry : 10.0; - - // We don't have a direct quantity field in TradeModel, but we calculate it. - // Let's use the explicit quantity if it exists, otherwise calculate it + + final calcQty = (initEntry > 0 && initPos > 0) ? (initPos / initEntry) : 10.0; final initQty = trade.quantity > 0 ? trade.quantity : calcQty; final actualEntryController = TextEditingController(text: initEntry.toStringAsFixed(2)); final positionSizeController = TextEditingController(text: initPos.toStringAsFixed(2)); final leverageController = TextEditingController(text: initLev.toStringAsFixed(1)); final quantityController = TextEditingController(text: initQty.toStringAsFixed(4)); - + final entryFeeController = TextEditingController(text: trade.entryFee.toStringAsFixed(2)); - final exitFeeController = TextEditingController(text: trade.exitFee.toStringAsFixed(2)); - + final slController = TextEditingController(text: trade.stopLoss.toString()); final tpController = TextEditingController(text: trade.takeProfit.toString()); + final derivativeIsinController = TextEditingController(text: trade.derivativeIsin); + + // Normalisierte Zuweisung verhindert den DropdownButton Assertion-Error + String selectedInstrumentType = _normalizeInstrumentType( + trade.instrumentType.isNotEmpty ? trade.instrumentType : 'KnockOut', + ); + + bool isFetchingDerivativePrice = false; + void recalculateQuantity() { - final entry = double.tryParse(actualEntryController.text) ?? 0.0; - final posSize = double.tryParse(positionSizeController.text) ?? 0.0; - final lev = double.tryParse(leverageController.text) ?? 1.0; + final entryStr = actualEntryController.text.replaceAll(',', '.').trim(); + final posStr = positionSizeController.text.replaceAll(',', '.').trim(); + + final entry = double.tryParse(entryStr) ?? 0.0; + final posSize = double.tryParse(posStr) ?? 0.0; if (entry > 0 && posSize > 0) { - final q = (posSize * lev) / entry; + final q = posSize / entry; quantityController.text = q.toStringAsFixed(4); } } + TradeAcceptanceDto buildDto() { + final isinVal = trade.isin.isNotEmpty ? trade.isin : (trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol); + final symbolVal = trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol; + + double parseNum(String text, double fallback) { + final clean = text.replaceAll(',', '.').trim(); + return double.tryParse(clean) ?? fallback; + } + + return TradeAcceptanceDto( + userId: trade.userId, + tradeId: trade.id, + analysisId: trade.analysisId, + isin: isinVal, + symbol: symbolVal, + actualEntryPrice: parseNum(actualEntryController.text, trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice), + positionSize: parseNum(positionSizeController.text, trade.positionSize > 0 ? trade.positionSize : 1000.0), + leverageUsed: parseNum(leverageController.text, trade.leverageUsed > 0 ? trade.leverageUsed : 1.0), + entryFee: parseNum(entryFeeController.text, trade.entryFee), + exitFee: parseNum(exitFeeController.text, trade.exitFee), + quantity: parseNum(quantityController.text, trade.quantity), + executionTimestamp: DateTime.now().toUtc(), + signalType: trade.signalType, + entryPrice: trade.entryPrice, + stopLoss: parseNum(slController.text, trade.stopLoss), + takeProfit: parseNum(tpController.text, trade.takeProfit), + instrumentType: selectedInstrumentType, + derivativeIsin: derivativeIsinController.text.trim(), + timeframe: trade.timeframe, + reasoning: trade.reasoning, + ); + } + + Future fetchDerivativePrice(StateSetter setModalState, String inputIsin) async { + 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, + ), + ); + return; + } + + setModalState(() => isFetchingDerivativePrice = true); + try { + final apiClient = context.read(); + final res = await apiClient.get('/api/v1/assets/$cleanIsin/technicals?forceRefresh=true'); + if (res.statusCode == 200 && res.data != null) { + final Map 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(); + } + + 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, + ), + ); + } finally { + setModalState(() => isFetchingDerivativePrice = false); + } + } + actualEntryController.addListener(recalculateQuantity); positionSizeController.addListener(recalculateQuantity); - leverageController.addListener(recalculateQuantity); showDialog( context: context, builder: (dialogContext) { - 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), - 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)), + return StatefulBuilder( + builder: (stfContext, setModalState) { + final isKnockout = selectedInstrumentType.toLowerCase().contains('knock') || + selectedInstrumentType.toLowerCase().contains('zertifikat') || + 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'; + + return AlertDialog( + backgroundColor: AppTheme.cardSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppTheme.glassBorder), ), - ], - ), - content: SizedBox( - width: 580, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + title: Row( children: [ - 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(14), - decoration: BoxDecoration( - color: signalColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: signalColor, width: 1.5), - ), - 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.toString(), style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)), - ), - ], - ), - Row( - children: [ - if (trade.winRate > 0) ...[ - 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: 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), - ], - ), - ], - ], - ), - ); - }, - ), - 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), - - Row( - children: [ - Expanded( - child: TextField( - controller: actualEntryController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Tatsächlicher Einstiegskurs (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: positionSizeController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Investitionsvolumen (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - ], - ), - const SizedBox(height: 10), - - Row( - children: [ - Expanded( - child: TextField( - controller: leverageController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Genutzter Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: quantityController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Stückzahl (Autom. berechnet)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - ], - ), - const SizedBox(height: 10), - - Row( - children: [ - Expanded( - child: TextField( - controller: entryFeeController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Einstiegsgebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: exitFeeController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Ausstiegsgebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - ], - ), - const SizedBox(height: 10), - - Row( - children: [ - Expanded( - child: TextField( - controller: slController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Stop-Loss (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: tpController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Take-Profit (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - ], + Icon(isActive ? Icons.tune : Icons.edit_note_outlined, color: AppTheme.primaryEmerald, size: 22), + 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), + ), ), ], ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), - ), - if (isActive) - ElevatedButton.icon( - onPressed: () { - final dto = TradeAcceptanceDto( - userId: trade.userId, - tradeId: trade.id, - analysisId: trade.analysisId, - isin: trade.isin, - symbol: trade.symbol, - actualEntryPrice: double.tryParse(actualEntryController.text) ?? trade.entryPrice, - positionSize: double.tryParse(positionSizeController.text) ?? 1000.0, - leverageUsed: double.tryParse(leverageController.text) ?? 1.0, - entryFee: double.tryParse(entryFeeController.text) ?? 0.0, - exitFee: double.tryParse(exitFeeController.text) ?? 0.0, - quantity: double.tryParse(quantityController.text) ?? 0.0, - executionTimestamp: DateTime.now().toUtc(), - signalType: trade.signalType, - entryPrice: trade.entryPrice, - stopLoss: double.tryParse(slController.text) ?? trade.stopLoss, - takeProfit: double.tryParse(tpController.text) ?? trade.takeProfit, - instrumentType: trade.instrumentType, - timeframe: trade.timeframe, - reasoning: trade.reasoning, - ); - onAccept(dto); - Navigator.pop(dialogContext); - }, - icon: const Icon(Icons.save, size: 16), - label: const Text('Speichern'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryEmerald, - foregroundColor: Colors.black, - ), - ) - else ...[ - if (onReject != null) - OutlinedButton.icon( - onPressed: () { - onReject(trade.id); - Navigator.pop(dialogContext); - }, - 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), + content: SizedBox( + width: 580, + child: SingleChildScrollView( + child: Column( + 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), + ), + 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), + ], + ), + ], + ], + ), + ); + }, + ), + 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( + value: 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))), + ], + onChanged: (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), + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + 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), + ), + ), + ], + ), + const SizedBox(height: 10), + ], + + Row( + children: [ + Expanded( + child: TextField( + controller: actualEntryController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Tatsächlicher Einstiegskurs (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: positionSizeController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Investitionsvolumen (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + ], + ), + const SizedBox(height: 10), + + Row( + children: [ + Expanded( + child: TextField( + controller: leverageController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Genutzter Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: quantityController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Stückzahl (Invest. / Einstieg)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + ], + ), + const SizedBox(height: 10), + + Row( + children: [ + Expanded( + child: TextField( + controller: entryFeeController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Einstiegsgebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: exitFeeController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Ausstiegsgebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + ], + ), + const SizedBox(height: 10), + + Row( + children: [ + Expanded( + child: TextField( + controller: slController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Stop-Loss (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: tpController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Take-Profit (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + ], + ), + ], ), ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: () { - final dto = TradeAcceptanceDto( - userId: trade.userId, - tradeId: trade.id, - analysisId: trade.analysisId, - isin: trade.isin, - symbol: trade.symbol, - actualEntryPrice: double.tryParse(actualEntryController.text) ?? trade.entryPrice, - positionSize: double.tryParse(positionSizeController.text) ?? 1000.0, - leverageUsed: double.tryParse(leverageController.text) ?? 1.0, - entryFee: double.tryParse(entryFeeController.text) ?? 0.0, - exitFee: double.tryParse(exitFeeController.text) ?? 0.0, - quantity: double.tryParse(quantityController.text) ?? 0.0, - executionTimestamp: DateTime.now().toUtc(), - signalType: trade.signalType, - entryPrice: trade.entryPrice, - stopLoss: double.tryParse(slController.text) ?? trade.stopLoss, - takeProfit: double.tryParse(tpController.text) ?? trade.takeProfit, - instrumentType: trade.instrumentType, - timeframe: trade.timeframe, - reasoning: trade.reasoning, - ); - onAccept(dto); - Navigator.of(dialogContext).pop(); - }, - icon: const Icon(Icons.check_circle, size: 16), - label: const Text('Trade Annehmen & Ausführen'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryEmerald, - foregroundColor: Colors.black, - ), ), - ], - ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), + ), + if (!isActive && onReject != null) + OutlinedButton.icon( + onPressed: () { + onReject(trade.id); + Navigator.pop(dialogContext); + }, + 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), + ), + ), + if (!isActive && onReject != null) const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: () { + final dto = buildDto(); + onAccept(dto); + Navigator.of(dialogContext).pop(); + }, + icon: Icon(isActive ? Icons.save : Icons.check_circle, size: 16), + label: Text(isActive ? 'Einstellungen Speichern' : 'Trade Annehmen & Ausführen'), + style: ElevatedButton.styleFrom( + backgroundColor: isActive ? AppTheme.accentCyan : AppTheme.primaryEmerald, + foregroundColor: Colors.black, + ), + ), + ], + ); + }, ); }, ); } - - static String _fmt(dynamic val) { if (val == null) return '0.00'; if (val is double) { @@ -396,7 +540,7 @@ class TradeExecutionDialog { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: TextStyle(color: Colors.white54, fontSize: 11)), + 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)), ], @@ -416,4 +560,4 @@ class TradeExecutionDialog { ), ); } -} +} \ No newline at end of file