From 1ccb6b613f6a145bde68cb8191702b86b6379cdf Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Sat, 15 Aug 2026 01:03:30 +0200 Subject: [PATCH] feat(asset_detail): modular fundamentals sections, executive salaries and logo resolution --- .../models/company_officer_model.dart | 50 ++ .../models/financial_statement_model.dart | 158 ++++ .../models/forward_estimate_model.dart | 36 + .../models/fundamental_data_model.dart | 558 ++---------- .../asset_detail/models/ticker_model.dart | 36 + .../repositories/asset_repository.dart | 135 +-- .../utils/metric_explanations.dart | 4 + .../utils/pattern_explanations.dart | 11 + .../views/asset_detail_screen.dart | 15 +- .../layouts/asset_page_desktop_layout.dart | 219 +++-- .../layouts/asset_page_mobile_layout.dart | 215 +++-- .../views/tabs/fundamentals_tab.dart | 590 ++----------- .../asset_detail/views/tabs/trades_tab.dart | 796 ++---------------- .../analyst_price_target_card.dart | 73 ++ .../fundamentals/company_profile_section.dart | 161 ++++ .../fundamental_category_panels.dart | 263 ++++++ .../widgets/header/asset_hero_header.dart | 63 +- .../widgets/trades/asset_trade_item_card.dart | 303 +++++++ .../widgets/trades/close_trade_dialog.dart | 73 ++ .../trades/live_trade_settings_dialog.dart | 159 ++++ .../trades/manual_analysis_dialog.dart | 174 ++++ 21 files changed, 2011 insertions(+), 2081 deletions(-) create mode 100644 FinlyticApp/lib/features/asset_detail/models/company_officer_model.dart create mode 100644 FinlyticApp/lib/features/asset_detail/models/financial_statement_model.dart create mode 100644 FinlyticApp/lib/features/asset_detail/models/forward_estimate_model.dart create mode 100644 FinlyticApp/lib/features/asset_detail/models/ticker_model.dart create mode 100644 FinlyticApp/lib/features/asset_detail/widgets/fundamentals/analyst_price_target_card.dart create mode 100644 FinlyticApp/lib/features/asset_detail/widgets/fundamentals/company_profile_section.dart create mode 100644 FinlyticApp/lib/features/asset_detail/widgets/fundamentals/fundamental_category_panels.dart create mode 100644 FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart create mode 100644 FinlyticApp/lib/features/asset_detail/widgets/trades/close_trade_dialog.dart create mode 100644 FinlyticApp/lib/features/asset_detail/widgets/trades/live_trade_settings_dialog.dart create mode 100644 FinlyticApp/lib/features/asset_detail/widgets/trades/manual_analysis_dialog.dart diff --git a/FinlyticApp/lib/features/asset_detail/models/company_officer_model.dart b/FinlyticApp/lib/features/asset_detail/models/company_officer_model.dart new file mode 100644 index 0000000..54ba1d6 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/models/company_officer_model.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; + +class CompanyExecutiveModel extends Equatable { + final String name; + final String title; + final int? age; + final double? compensation; + final String? payment; + + const CompanyExecutiveModel({ + required this.name, + required this.title, + this.age, + this.compensation, + this.payment, + }); + + factory CompanyExecutiveModel.fromJson(Map json) { + double? compVal; + if (json['compensation'] != null) { + compVal = (json['compensation'] as num?)?.toDouble() ?? double.tryParse(json['compensation'].toString()); + } + + final rawPayment = json['payment']?.toString(); + if (compVal == null && rawPayment != null && rawPayment.isNotEmpty) { + compVal = double.tryParse(rawPayment); + } + + return CompanyExecutiveModel( + name: json['name']?.toString() ?? '', + title: json['title']?.toString() ?? '', + age: json['age'] != null ? int.tryParse(json['age'].toString()) : null, + compensation: compVal, + payment: rawPayment, + ); + } + + Map toJson() { + return { + 'name': name, + 'title': title, + if (age != null) 'age': age, + if (compensation != null) 'compensation': compensation, + if (payment != null) 'payment': payment, + }; + } + + @override + List get props => [name, title, age, compensation, payment]; +} diff --git a/FinlyticApp/lib/features/asset_detail/models/financial_statement_model.dart b/FinlyticApp/lib/features/asset_detail/models/financial_statement_model.dart new file mode 100644 index 0000000..20b5ca9 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/models/financial_statement_model.dart @@ -0,0 +1,158 @@ +import 'package:equatable/equatable.dart'; + +class FinancialStatementModel extends Equatable { + final String periodType; + final String endDate; + + // Income Statement + final double? totalRevenue; + final double? costOfRevenue; + final double? grossProfit; + final double? operatingExpenses; + final double? operatingIncome; + final double? ebitda; + final double? netIncome; + final double? epsBasic; + final double? epsDiluted; + + // Balance Sheet + final double? cashAndCashEquivalents; + final double? accountsReceivable; + final double? inventory; + final double? totalCurrentAssets; + final double? totalNonCurrentAssets; + final double? currentLiabilities; + final double? longTermDebt; + final double? totalLiabilities; + final double? totalStockholdersEquity; + + // Cash Flow + final double? operatingCashFlow; + final double? investingCashFlow; + final double? capitalExpenditures; + final double? financingCashFlow; + final double? freeCashFlow; + + const FinancialStatementModel({ + required this.periodType, + required this.endDate, + this.totalRevenue, + this.costOfRevenue, + this.grossProfit, + this.operatingExpenses, + this.operatingIncome, + this.ebitda, + this.netIncome, + this.epsBasic, + this.epsDiluted, + this.cashAndCashEquivalents, + this.accountsReceivable, + this.inventory, + this.totalCurrentAssets, + this.totalNonCurrentAssets, + this.currentLiabilities, + this.longTermDebt, + this.totalLiabilities, + this.totalStockholdersEquity, + this.operatingCashFlow, + this.investingCashFlow, + this.capitalExpenditures, + this.financingCashFlow, + this.freeCashFlow, + }); + + factory FinancialStatementModel.fromJson(Map json) { + double? parseD(dynamic val) { + if (val == null) return null; + if (val is num) return val.toDouble(); + return double.tryParse(val.toString()); + } + + return FinancialStatementModel( + periodType: json['periodType']?.toString() ?? '', + endDate: json['endDate']?.toString() ?? '', + totalRevenue: parseD(json['totalRevenue']), + costOfRevenue: parseD(json['costOfRevenue']), + grossProfit: parseD(json['grossProfit']), + operatingExpenses: parseD(json['operatingExpenses']), + operatingIncome: parseD(json['operatingIncome']), + ebitda: parseD(json['ebitda']), + netIncome: parseD(json['netIncome']), + epsBasic: parseD(json['epsBasic']), + epsDiluted: parseD(json['epsDiluted']), + cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']), + accountsReceivable: parseD(json['accountsReceivable']), + inventory: parseD(json['inventory']), + totalCurrentAssets: parseD(json['totalCurrentAssets']), + totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']), + currentLiabilities: parseD(json['currentLiabilities']), + longTermDebt: parseD(json['longTermDebt']), + totalLiabilities: parseD(json['totalLiabilities']), + totalStockholdersEquity: parseD(json['totalStockholdersEquity']), + operatingCashFlow: parseD(json['operatingCashFlow']), + investingCashFlow: parseD(json['investingCashFlow']), + capitalExpenditures: parseD(json['capitalExpenditures']), + financingCashFlow: parseD(json['financingCashFlow']), + freeCashFlow: parseD(json['freeCashFlow']), + ); + } + + Map toJson() { + return { + 'periodType': periodType, + 'endDate': endDate, + 'totalRevenue': totalRevenue, + 'costOfRevenue': costOfRevenue, + 'grossProfit': grossProfit, + 'operatingExpenses': operatingExpenses, + 'operatingIncome': operatingIncome, + 'ebitda': ebitda, + 'netIncome': netIncome, + 'epsBasic': epsBasic, + 'epsDiluted': epsDiluted, + 'cashAndCashEquivalents': cashAndCashEquivalents, + 'accountsReceivable': accountsReceivable, + 'inventory': inventory, + 'totalCurrentAssets': totalCurrentAssets, + 'totalNonCurrentAssets': totalNonCurrentAssets, + 'currentLiabilities': currentLiabilities, + 'longTermDebt': longTermDebt, + 'totalLiabilities': totalLiabilities, + 'totalStockholdersEquity': totalStockholdersEquity, + 'operatingCashFlow': operatingCashFlow, + 'investingCashFlow': investingCashFlow, + 'capitalExpenditures': capitalExpenditures, + 'financingCashFlow': financingCashFlow, + 'freeCashFlow': freeCashFlow, + }; + } + + @override + List get props => [ + periodType, + endDate, + totalRevenue, + costOfRevenue, + grossProfit, + operatingExpenses, + operatingIncome, + ebitda, + netIncome, + epsBasic, + epsDiluted, + cashAndCashEquivalents, + accountsReceivable, + inventory, + totalCurrentAssets, + totalNonCurrentAssets, + currentLiabilities, + longTermDebt, + totalLiabilities, + totalStockholdersEquity, + operatingCashFlow, + investingCashFlow, + capitalExpenditures, + financingCashFlow, + freeCashFlow, + ]; +} diff --git a/FinlyticApp/lib/features/asset_detail/models/forward_estimate_model.dart b/FinlyticApp/lib/features/asset_detail/models/forward_estimate_model.dart new file mode 100644 index 0000000..824a1c4 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/models/forward_estimate_model.dart @@ -0,0 +1,36 @@ +import 'package:equatable/equatable.dart'; + +class ForwardEstimateModel extends Equatable { + final String period; + final double? expectedRevenue; + final double? expectedEps; + final double? expectedGrowthRate; + + const ForwardEstimateModel({ + required this.period, + this.expectedRevenue, + this.expectedEps, + this.expectedGrowthRate, + }); + + factory ForwardEstimateModel.fromJson(Map json) { + return ForwardEstimateModel( + period: json['period']?.toString() ?? '', + expectedRevenue: (json['expectedRevenue'] as num?)?.toDouble() ?? (json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null), + expectedEps: (json['expectedEps'] as num?)?.toDouble() ?? (json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null), + expectedGrowthRate: (json['expectedGrowthRate'] as num?)?.toDouble() ?? (json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null), + ); + } + + Map toJson() { + return { + 'period': period, + if (expectedRevenue != null) 'expectedRevenue': expectedRevenue, + if (expectedEps != null) 'expectedEps': expectedEps, + if (expectedGrowthRate != null) 'expectedGrowthRate': expectedGrowthRate, + }; + } + + @override + List get props => [period, expectedRevenue, expectedEps, expectedGrowthRate]; +} 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 8e62a0b..f845e7c 100644 --- a/FinlyticApp/lib/features/asset_detail/models/fundamental_data_model.dart +++ b/FinlyticApp/lib/features/asset_detail/models/fundamental_data_model.dart @@ -1,4 +1,13 @@ import 'package:equatable/equatable.dart'; +import 'ticker_model.dart'; +import 'company_officer_model.dart'; +import 'financial_statement_model.dart'; +import 'forward_estimate_model.dart'; + +export 'ticker_model.dart'; +export 'company_officer_model.dart'; +export 'financial_statement_model.dart'; +export 'forward_estimate_model.dart'; class FundamentalDataModel extends Equatable { final String isin; @@ -16,10 +25,10 @@ class FundamentalDataModel extends Equatable { final double currentPrice; final double dayChangeAbsolute; final double dayChangePercent; - final double fiftyTwoWeekHigh; - final double fiftyTwoWeekLow; - final double marketCapitalization; - final double enterpriseValue; + final double? fiftyTwoWeekHigh; + final double? fiftyTwoWeekLow; + final double? marketCapitalization; + final double? enterpriseValue; final double? peRatioTrailing; final double? peRatioForward; @@ -85,10 +94,10 @@ class FundamentalDataModel extends Equatable { required this.currentPrice, required this.dayChangeAbsolute, required this.dayChangePercent, - required this.fiftyTwoWeekHigh, - required this.fiftyTwoWeekLow, - required this.marketCapitalization, - required this.enterpriseValue, + this.fiftyTwoWeekHigh, + this.fiftyTwoWeekLow, + this.marketCapitalization, + this.enterpriseValue, this.peRatioTrailing, this.peRatioForward, this.pegRatio, @@ -135,12 +144,6 @@ class FundamentalDataModel extends Equatable { }); factory FundamentalDataModel.fromJson(Map json) { - double parseDouble(dynamic val) { - if (val == null) return 0.0; - if (val is num) return val.toDouble(); - return double.tryParse(val.toString()) ?? 0.0; - } - double? parseNullableDouble(dynamic val) { if (val == null) return null; if (val is num) return val.toDouble(); @@ -171,8 +174,8 @@ class FundamentalDataModel extends Equatable { 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 companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? tickerVal; + final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString(); final exchangeVal = extractExchangeStr(fundMap?['ticker']) ?? extractExchangeStr(assetMap?['primaryTicker']) ?? @@ -181,16 +184,12 @@ class FundamentalDataModel extends Equatable { 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(); + availableTickersList = rawTickers + .whereType>() + .map((t) => TickerModel.fromJson(t)) + .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']); @@ -202,96 +201,52 @@ class FundamentalDataModel extends Equatable { } } - // 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: isinVal, primaryTicker: primaryTickerVal, ticker: tickerVal, companyName: companyNameVal, exchange: exchangeVal, - tradingCurrency: json['tradingCurrency']?.toString(), + tradingCurrency: fundMap?['currency']?.toString() ?? json['tradingCurrency']?.toString(), businessSummary: businessSummaryVal, - sector: json['sector']?.toString(), - industry: json['industry']?.toString(), - country: json['country']?.toString(), - employees: json['employees'] != null ? int.tryParse(json['employees'].toString()) : null, - currentPrice: parseDouble(json['currentPrice']), - dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']), - dayChangePercent: parseDouble(json['dayChangePercent']), - 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']), + sector: assetMap?['sector']?.toString() ?? json['sector']?.toString(), + industry: assetMap?['industry']?.toString() ?? json['industry']?.toString(), + country: assetMap?['country']?.toString() ?? json['country']?.toString(), + employees: (assetMap?['employees'] ?? json['employees']) is int + ? (assetMap?['employees'] ?? json['employees']) as int + : int.tryParse((assetMap?['employees'] ?? json['employees'])?.toString() ?? ''), + currentPrice: parseNullableDouble(fundMap?['currentPrice'] ?? json['currentPrice']) ?? 0.0, + dayChangeAbsolute: parseNullableDouble(fundMap?['dayChangeAbsolute'] ?? json['dayChangeAbsolute']) ?? 0.0, + dayChangePercent: parseNullableDouble(fundMap?['dayChangePercent'] ?? json['dayChangePercent']) ?? 0.0, + fiftyTwoWeekHigh: parseNullableDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']), + fiftyTwoWeekLow: parseNullableDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']), + marketCapitalization: parseNullableDouble(fundMap?['marketCap'] ?? fundMap?['marketCapitalization'] ?? json['marketCapitalization']), + enterpriseValue: evVal, + peRatioTrailing: parseNullableDouble(fundMap?['trailingPE'] ?? fundMap?['peRatioTrailing'] ?? json['peRatioTrailing']), + peRatioForward: parseNullableDouble(fundMap?['forwardPE'] ?? fundMap?['peRatioForward'] ?? 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']), + pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? fundMap?['pbRatio'] ?? json['pbRatio']), + psRatio: parseNullableDouble(fundMap?['priceToSalesTrailing12Months'] ?? fundMap?['psRatio'] ?? json['psRatio']), + evToEbitda: parseNullableDouble(fundMap?['enterpriseToEbitda'] ?? fundMap?['evToEbitda'] ?? json['evToEbitda']), evToRevenue: evToRevVal, totalRevenue: totalRev, - revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']), + revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowth'] ?? fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']), grossProfit: grossProf, ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']), - dilutedEps: parseNullableDouble(fundMap?['dilutedEps'] ?? json['dilutedEps']), + dilutedEps: parseNullableDouble(fundMap?['trailingEps'] ?? 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']), + operatingCashFlow: parseNullableDouble(fundMap?['operatingCashflow'] ?? fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']), + freeCashFlow: parseNullableDouble(fundMap?['freeCashflow'] ?? fundMap?['freeCashFlow'] ?? json['freeCashFlow']), grossMargin: grossMarginVal, - operatingMargin: parseNullableDouble(fundMap?['operatingMargin'] ?? fundMap?['operatingIncome'] ?? json['operatingMargin']), - netProfitMargin: parseNullableDouble(fundMap?['netProfitMargin'] ?? fundMap?['netIncome'] ?? json['netProfitMargin']), + operatingMargin: parseNullableDouble(fundMap?['operatingMargins'] ?? fundMap?['operatingMargin'] ?? json['operatingMargin']), + netProfitMargin: parseNullableDouble(fundMap?['profitMargins'] ?? fundMap?['netProfitMargin'] ?? json['netProfitMargin']), returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']), returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']), returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']), @@ -299,424 +254,51 @@ class FundamentalDataModel extends Equatable { currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']), quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']), interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']), - dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? json['dividendYield']), + dividendYield: parseNullableDouble(fundMap?['dividendYield'] ?? json['dividendYield']), payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']), - exDividendDate: exDividendDateVal, - nextEarningsDate: nextEarningsDateVal, + exDividendDate: fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString(), + nextEarningsDate: fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString(), 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(), + consensusRating: fundMap?['consensusRating']?.toString() ?? 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 is Map ? e : {})) + ?.whereType>() + .map((e) => CompanyExecutiveModel.fromJson(e)) .toList() ?? [], financialStatements: (json['financialStatements'] as List?) - ?.map((e) => FinancialStatementModel.fromJson(e is Map ? e : {})) + ?.whereType>() + .map((e) => FinancialStatementModel.fromJson(e)) .toList() ?? [], estimates: (json['estimates'] as List?) - ?.map((e) => ForwardEstimateModel.fromJson(e is Map ? e : {})) + ?.whereType>() + .map((e) => ForwardEstimateModel.fromJson(e)) .toList() ?? [], availableTickers: availableTickersList, ); } - Map toJson() { - return { - 'isin': isin, - 'primaryTicker': primaryTicker, - 'ticker': ticker, - 'companyName': companyName, - 'exchange': exchange, - 'tradingCurrency': tradingCurrency, - 'businessSummary': businessSummary, - 'sector': sector, - 'industry': industry, - 'country': country, - 'employees': employees, - 'currentPrice': currentPrice, - 'dayChangeAbsolute': dayChangeAbsolute, - 'dayChangePercent': dayChangePercent, - 'fiftyTwoWeekHigh': fiftyTwoWeekHigh, - 'fiftyTwoWeekLow': fiftyTwoWeekLow, - 'marketCapitalization': marketCapitalization, - 'enterpriseValue': enterpriseValue, - 'peRatioTrailing': peRatioTrailing, - 'peRatioForward': peRatioForward, - 'pegRatio': pegRatio, - 'pbRatio': pbRatio, - 'psRatio': psRatio, - 'evToEbitda': evToEbitda, - 'evToRevenue': evToRevenue, - 'grossMargin': grossMargin, - 'operatingMargin': operatingMargin, - 'netProfitMargin': netProfitMargin, - 'returnOnEquity': returnOnEquity, - 'returnOnAssets': returnOnAssets, - 'returnOnInvestedCapital': returnOnInvestedCapital, - 'debtToEquity': debtToEquity, - 'currentRatio': currentRatio, - 'quickRatio': quickRatio, - 'dividendYield': dividendYield, - 'payoutRatio': payoutRatio, - 'exDividendDate': exDividendDate, - 'nextEarningsDate': nextEarningsDate, - 'percentHeldByInstitutions': percentHeldByInstitutions, - 'percentHeldByInsiders': percentHeldByInsiders, - 'shortRatio': shortRatio, - 'shortPercentOfFloat': shortPercentOfFloat, - 'consensusRating': consensusRating, - 'priceTargetLow': priceTargetLow, - 'priceTargetHigh': priceTargetHigh, - 'priceTargetMedian': priceTargetMedian, - 'priceTargetMean': priceTargetMean, - 'executives': executives.map((e) => e.toJson()).toList(), - 'financialStatements': financialStatements.map((e) => e.toJson()).toList(), - 'estimates': estimates.map((e) => e.toJson()).toList(), - 'availableTickers': availableTickers.map((e) => e.toJson()).toList(), - }; - } - @override List get props => [ - isin, - primaryTicker, - ticker, - companyName, - exchange, - tradingCurrency, - businessSummary, - sector, - industry, - country, - employees, - currentPrice, - dayChangeAbsolute, - dayChangePercent, - fiftyTwoWeekHigh, - fiftyTwoWeekLow, - marketCapitalization, - enterpriseValue, - peRatioTrailing, - peRatioForward, - pegRatio, - pbRatio, - psRatio, - evToEbitda, - evToRevenue, - grossMargin, - operatingMargin, - netProfitMargin, - returnOnEquity, - returnOnAssets, - returnOnInvestedCapital, - debtToEquity, - currentRatio, - quickRatio, - dividendYield, - payoutRatio, - exDividendDate, - nextEarningsDate, - percentHeldByInstitutions, - percentHeldByInsiders, - shortRatio, - shortPercentOfFloat, - consensusRating, - priceTargetLow, - priceTargetHigh, - priceTargetMedian, - priceTargetMean, - executives, - financialStatements, - estimates, - availableTickers, + isin, primaryTicker, ticker, companyName, exchange, tradingCurrency, + businessSummary, sector, industry, country, employees, currentPrice, + dayChangeAbsolute, dayChangePercent, fiftyTwoWeekHigh, fiftyTwoWeekLow, + marketCapitalization, enterpriseValue, peRatioTrailing, peRatioForward, + pegRatio, pbRatio, psRatio, evToEbitda, evToRevenue, grossMargin, + operatingMargin, netProfitMargin, returnOnEquity, returnOnAssets, + returnOnInvestedCapital, debtToEquity, currentRatio, quickRatio, + dividendYield, payoutRatio, exDividendDate, nextEarningsDate, + percentHeldByInstitutions, percentHeldByInsiders, shortRatio, + shortPercentOfFloat, consensusRating, priceTargetLow, priceTargetHigh, + priceTargetMedian, priceTargetMean, executives, financialStatements, + estimates, availableTickers, ]; } - -class CompanyExecutiveModel extends Equatable { - final String name; - final String title; - final int? age; - final double? compensation; - - const CompanyExecutiveModel({ - required this.name, - required this.title, - this.age, - this.compensation, - }); - - 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: compVal, - ); - } - - Map toJson() { - return { - 'name': name, - 'title': title, - 'age': age, - 'compensation': compensation, - }; - } - - @override - List get props => [name, title, age, compensation]; -} - -class FinancialStatementModel extends Equatable { - final String periodType; - final String endDate; - - // Income Statement - final double? totalRevenue; - final double? costOfRevenue; - final double? grossProfit; - final double? operatingExpenses; - final double? operatingIncome; - final double? ebitda; - final double? netIncome; - final double? epsBasic; - final double? epsDiluted; - - // Balance Sheet - final double? cashAndCashEquivalents; - final double? accountsReceivable; - final double? inventory; - final double? totalCurrentAssets; - final double? totalNonCurrentAssets; - final double? currentLiabilities; - final double? longTermDebt; - final double? totalLiabilities; - final double? totalStockholdersEquity; - - // Cash Flow - final double? operatingCashFlow; - final double? investingCashFlow; - final double? capitalExpenditures; - final double? financingCashFlow; - final double? freeCashFlow; - - const FinancialStatementModel({ - required this.periodType, - required this.endDate, - this.totalRevenue, - this.costOfRevenue, - this.grossProfit, - this.operatingExpenses, - this.operatingIncome, - this.ebitda, - this.netIncome, - this.epsBasic, - this.epsDiluted, - this.cashAndCashEquivalents, - this.accountsReceivable, - this.inventory, - this.totalCurrentAssets, - this.totalNonCurrentAssets, - this.currentLiabilities, - this.longTermDebt, - this.totalLiabilities, - this.totalStockholdersEquity, - this.operatingCashFlow, - this.investingCashFlow, - this.capitalExpenditures, - this.financingCashFlow, - this.freeCashFlow, - }); - - factory FinancialStatementModel.fromJson(Map json) { - double? parseD(dynamic val) { - if (val == null) return null; - if (val is num) return val.toDouble(); - return double.tryParse(val.toString()); - } - - return FinancialStatementModel( - periodType: json['periodType']?.toString() ?? '', - endDate: json['endDate']?.toString() ?? '', - totalRevenue: parseD(json['totalRevenue']), - costOfRevenue: parseD(json['costOfRevenue']), - grossProfit: parseD(json['grossProfit']), - operatingExpenses: parseD(json['operatingExpenses']), - operatingIncome: parseD(json['operatingIncome']), - ebitda: parseD(json['ebitda']), - netIncome: parseD(json['netIncome']), - epsBasic: parseD(json['epsBasic']), - epsDiluted: parseD(json['epsDiluted']), - cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']), - accountsReceivable: parseD(json['accountsReceivable']), - inventory: parseD(json['inventory']), - totalCurrentAssets: parseD(json['totalCurrentAssets']), - totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']), - currentLiabilities: parseD(json['currentLiabilities']), - longTermDebt: parseD(json['longTermDebt']), - totalLiabilities: parseD(json['totalLiabilities']), - totalStockholdersEquity: parseD(json['totalStockholdersEquity']), - operatingCashFlow: parseD(json['operatingCashFlow']), - investingCashFlow: parseD(json['investingCashFlow']), - capitalExpenditures: parseD(json['capitalExpenditures']), - financingCashFlow: parseD(json['financingCashFlow']), - freeCashFlow: parseD(json['freeCashFlow']), - ); - } - - Map toJson() { - return { - 'periodType': periodType, - 'endDate': endDate, - 'totalRevenue': totalRevenue, - 'costOfRevenue': costOfRevenue, - 'grossProfit': grossProfit, - 'operatingExpenses': operatingExpenses, - 'operatingIncome': operatingIncome, - 'ebitda': ebitda, - 'netIncome': netIncome, - 'epsBasic': epsBasic, - 'epsDiluted': epsDiluted, - 'cashAndCashEquivalents': cashAndCashEquivalents, - 'accountsReceivable': accountsReceivable, - 'inventory': inventory, - 'totalCurrentAssets': totalCurrentAssets, - 'totalNonCurrentAssets': totalNonCurrentAssets, - 'currentLiabilities': currentLiabilities, - 'longTermDebt': longTermDebt, - 'totalLiabilities': totalLiabilities, - 'totalStockholdersEquity': totalStockholdersEquity, - 'operatingCashFlow': operatingCashFlow, - 'investingCashFlow': investingCashFlow, - 'capitalExpenditures': capitalExpenditures, - 'financingCashFlow': financingCashFlow, - 'freeCashFlow': freeCashFlow, - }; - } - - @override - List get props => [ - periodType, - endDate, - totalRevenue, - costOfRevenue, - grossProfit, - operatingExpenses, - operatingIncome, - ebitda, - netIncome, - epsBasic, - epsDiluted, - cashAndCashEquivalents, - accountsReceivable, - inventory, - totalCurrentAssets, - totalNonCurrentAssets, - currentLiabilities, - longTermDebt, - totalLiabilities, - totalStockholdersEquity, - operatingCashFlow, - investingCashFlow, - capitalExpenditures, - financingCashFlow, - freeCashFlow, - ]; -} - -class ForwardEstimateModel extends Equatable { - final String period; - final double? expectedRevenue; - final double? expectedEps; - final double? expectedGrowthRate; - - const ForwardEstimateModel({ - required this.period, - this.expectedRevenue, - this.expectedEps, - this.expectedGrowthRate, - }); - - factory ForwardEstimateModel.fromJson(Map json) { - return ForwardEstimateModel( - period: json['period']?.toString() ?? '', - expectedRevenue: json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null, - expectedEps: json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null, - expectedGrowthRate: json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null, - ); - } - - Map toJson() { - return { - 'period': period, - 'expectedRevenue': expectedRevenue, - 'expectedEps': expectedEps, - 'expectedGrowthRate': expectedGrowthRate, - }; - } - - @override - List get props => [period, expectedRevenue, expectedEps, expectedGrowthRate]; -} - -class TickerModel extends Equatable { - final String ticker; - final String? exchange; - final String? tradingCurrency; - final double currentPrice; - - const TickerModel({ - required this.ticker, - this.exchange, - this.tradingCurrency, - this.currentPrice = 0.0, - }); - - factory TickerModel.fromJson(Map json) { - return TickerModel( - ticker: json['ticker']?.toString() ?? '', - exchange: json['exchange']?.toString(), - tradingCurrency: json['tradingCurrency']?.toString(), - currentPrice: json['currentPrice'] != null ? double.tryParse(json['currentPrice'].toString()) ?? 0.0 : 0.0, - ); - } - - Map toJson() { - return { - 'ticker': ticker, - 'exchange': exchange, - 'tradingCurrency': tradingCurrency, - 'currentPrice': currentPrice, - }; - } - - @override - List get props => [ticker, exchange, tradingCurrency, currentPrice]; -} diff --git a/FinlyticApp/lib/features/asset_detail/models/ticker_model.dart b/FinlyticApp/lib/features/asset_detail/models/ticker_model.dart new file mode 100644 index 0000000..995e695 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/models/ticker_model.dart @@ -0,0 +1,36 @@ +import 'package:equatable/equatable.dart'; + +class TickerModel extends Equatable { + final String ticker; + final String? exchange; + final String? tradingCurrency; + final double? currentPrice; + + const TickerModel({ + required this.ticker, + this.exchange, + this.tradingCurrency, + this.currentPrice, + }); + + factory TickerModel.fromJson(Map json) { + return TickerModel( + ticker: json['ticker']?.toString() ?? '', + exchange: json['exchange']?.toString(), + tradingCurrency: json['tradingCurrency']?.toString(), + currentPrice: (json['currentPrice'] as num?)?.toDouble(), + ); + } + + Map toJson() { + return { + 'ticker': ticker, + if (exchange != null) 'exchange': exchange, + if (tradingCurrency != null) 'tradingCurrency': tradingCurrency, + if (currentPrice != null) 'currentPrice': currentPrice, + }; + } + + @override + List get props => [ticker, exchange, tradingCurrency, currentPrice]; +} diff --git a/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart b/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart index 3e96c21..e4823b4 100644 --- a/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart +++ b/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart @@ -1,103 +1,124 @@ 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'; +import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart'; +import 'package:finlytic_app/features/trades/repositories/trade_repository.dart'; class AssetRepository { final ApiClient apiClient; + final TradeRepository _tradeRepository; - AssetRepository({required this.apiClient}); + // In-memory request deduplication & cache + final Map> _pendingFundamentals = {}; + final Map _fundamentalsCache = {}; + + final Map> _pendingTechnicals = {}; + final Map _technicalsCache = {}; + + AssetRepository({required this.apiClient, TradeRepository? tradeRepository}) + : _tradeRepository = tradeRepository ?? TradeRepository(apiClient: apiClient); + + String _buildCacheKey(String isin, String? ticker) => '${isin.toUpperCase()}_${(ticker ?? '').toUpperCase()}'; Future getAssetFundamentals(String isin, bool forceRefresh, {String? ticker}) async { + final key = _buildCacheKey(isin, ticker); + + if (!forceRefresh && _fundamentalsCache.containsKey(key)) { + return _fundamentalsCache[key]; + } + + if (_pendingFundamentals.containsKey(key)) { + return await _pendingFundamentals[key]; + } + + final future = _fetchFundamentals(isin, forceRefresh, ticker: ticker); + _pendingFundamentals[key] = future; + + try { + final result = await future; + if (result != null) { + _fundamentalsCache[key] = result; + } + return result; + } finally { + _pendingFundamentals.remove(key); + } + } + + Future _fetchFundamentals(String isin, bool forceRefresh, {String? ticker}) async { try { String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh'; if (ticker != null && ticker.isNotEmpty) { url += '&ticker=$ticker'; } final res = await apiClient.get(url); - if (res.statusCode == 200 && res.data != null) { + if (res.statusCode == 200 && res.data != null && res.data is Map) { return FundamentalDataModel.fromJson(res.data); } - } catch (e) { - print('Error fetching fundamentals for $isin: $e'); - } + } catch (_) {} return null; } Future getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async { + final key = _buildCacheKey(isin, ticker); + + if (!forceRefresh && _technicalsCache.containsKey(key)) { + return _technicalsCache[key]; + } + + if (_pendingTechnicals.containsKey(key)) { + return await _pendingTechnicals[key]; + } + + final future = _fetchTechnicals(isin, forceRefresh, ticker: ticker); + _pendingTechnicals[key] = future; + + try { + final result = await future; + if (result != null) { + _technicalsCache[key] = result; + } + return result; + } finally { + _pendingTechnicals.remove(key); + } + } + + Future _fetchTechnicals(String isin, bool forceRefresh, {String? ticker}) async { try { String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh'; if (ticker != null && ticker.isNotEmpty) { url += '&ticker=$ticker'; } final res = await apiClient.get(url); - if (res.statusCode == 200 && res.data != null) { + if (res.statusCode == 200 && res.data != null && res.data is Map) { return TechnicalAnalysisModel.fromJson(res.data); } - } catch (e) { - print('Error fetching TA for $isin: $e'); - } + } catch (_) {} return null; } Future> getAssetTrades(String isin, String? status) async { - try { - String url = '/api/v1/user/trades?isin=$isin'; - if (status != null) url += '&status=$status'; - final res = await apiClient.get(url); - if (res.statusCode == 200 && res.data != null) { - final List list = res.data; - return list.map((json) => TradeModel.fromJson(json)).toList(); - } - } catch (e) { - print('Error fetching trades for $isin: $e'); - } - return []; + return _tradeRepository.fetchTrades(isin: isin, status: status); } Future triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async { - try { - final body = payload != null ? payload.toJson() : {'isin': isin}; - 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'); - rethrow; + final body = payload != null ? payload.toJson() : {'isin': isin}; + 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; } - Future rejectTrade(String tradeId) async { - try { - await apiClient.post('/api/v1/user/trades/$tradeId/reject'); - } catch (e) { - print('Error rejecting trade $tradeId: $e'); - throw e; - } - } + Future rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId); - Future acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async { - try { - final payload = tradeAcceptanceDto.toJson(); - await apiClient.post('/api/v1/user/trades/accept', data: payload); - } catch (e) { - print('Error accepting trade: $e'); - throw e; - } - } + Future acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto); - Future closeTrade(String tradeId, double exitPrice) async { - try { - await apiClient.post('/api/v1/user/trades/$tradeId/close', data: {'userExitPrice': exitPrice}); - } catch (e) { - print('Error closing trade $tradeId: $e'); - throw e; - } - } + Future closeTrade(String tradeId, double exitPrice) async => + _tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice)); } diff --git a/FinlyticApp/lib/features/asset_detail/utils/metric_explanations.dart b/FinlyticApp/lib/features/asset_detail/utils/metric_explanations.dart index cfaa0e3..dcefea3 100644 --- a/FinlyticApp/lib/features/asset_detail/utils/metric_explanations.dart +++ b/FinlyticApp/lib/features/asset_detail/utils/metric_explanations.dart @@ -125,6 +125,10 @@ class MetricExplanations { }, }; + static bool hasExplanation(String key) => data.containsKey(key); + + static void showModal(BuildContext context, String key) => show(context, key); + static void show(BuildContext context, String key) { final info = data[key]; if (info == null) return; diff --git a/FinlyticApp/lib/features/asset_detail/utils/pattern_explanations.dart b/FinlyticApp/lib/features/asset_detail/utils/pattern_explanations.dart index f15d4aa..4472d7f 100644 --- a/FinlyticApp/lib/features/asset_detail/utils/pattern_explanations.dart +++ b/FinlyticApp/lib/features/asset_detail/utils/pattern_explanations.dart @@ -117,6 +117,17 @@ class PatternExplanations { return colors[patternType.hashCode.abs() % colors.length]; } + static String getGermanName(String rawPatternType) { + final key = dictionary.keys.firstWhere( + (k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()), + orElse: () => '', + ); + if (key.isNotEmpty && dictionary.containsKey(key)) { + return dictionary[key]!['title'] ?? rawPatternType; + } + return rawPatternType; + } + static void showPatternDetails(BuildContext context, String rawPatternType) { final key = dictionary.keys.firstWhere( (k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()), diff --git a/FinlyticApp/lib/features/asset_detail/views/asset_detail_screen.dart b/FinlyticApp/lib/features/asset_detail/views/asset_detail_screen.dart index c5a7797..98b92f7 100644 --- a/FinlyticApp/lib/features/asset_detail/views/asset_detail_screen.dart +++ b/FinlyticApp/lib/features/asset_detail/views/asset_detail_screen.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/network/api_client.dart'; import '../bloc/fundamentals/asset_fundamentals_bloc.dart'; -import '../bloc/header/asset_header_bloc.dart'; -import '../bloc/header/asset_header_event.dart'; +import '../bloc/fundamentals/asset_fundamentals_event.dart'; import '../bloc/technical/asset_technical_bloc.dart'; +import '../bloc/technical/asset_technical_event.dart'; import '../bloc/trades/asset_trades_bloc.dart'; import '../bloc/trades/asset_trades_event.dart'; import '../repositories/asset_repository.dart'; @@ -32,14 +32,12 @@ class AssetDetailScreen extends StatelessWidget { return MultiBlocProvider( providers: [ BlocProvider( - create: (context) => AssetHeaderBloc(repository: repository) - ..add(LoadAssetHeader(isin, ticker: symbol)), + create: (context) => AssetFundamentalsBloc(repository: repository) + ..add(LoadAssetFundamentals(isin, ticker: symbol)), ), BlocProvider( - create: (context) => AssetFundamentalsBloc(repository: repository), - ), - BlocProvider( - create: (context) => AssetTechnicalBloc(repository: repository), + create: (context) => AssetTechnicalBloc(repository: repository) + ..add(LoadAssetTechnical(isin, ticker: symbol)), ), BlocProvider( create: (context) => AssetTradesBloc(repository: repository) @@ -67,3 +65,4 @@ class AssetDetailScreen extends StatelessWidget { ); } } + 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 47b92cc..93207e4 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 @@ -4,9 +4,6 @@ import '../../../../core/theme/app_theme.dart'; import '../../../favorites/cubit/favorites_cubit.dart'; import '../../bloc/fundamentals/asset_fundamentals_bloc.dart'; import '../../bloc/fundamentals/asset_fundamentals_event.dart'; -import '../../bloc/header/asset_header_bloc.dart'; -import '../../bloc/header/asset_header_event.dart'; -import '../../bloc/header/asset_header_state.dart'; import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_event.dart'; import '../../bloc/trades/asset_trades_bloc.dart'; @@ -21,8 +18,12 @@ class AssetPageDesktopLayout extends StatefulWidget { final String? name; final String? selectedTicker; - const AssetPageDesktopLayout( - {super.key, required this.isin, this.selectedTicker, this.name}); + const AssetPageDesktopLayout({ + super.key, + required this.isin, + this.selectedTicker, + this.name, + }); @override State createState() => _AssetPageDesktopLayoutState(); @@ -31,12 +32,12 @@ class AssetPageDesktopLayout extends StatefulWidget { class _AssetPageDesktopLayoutState extends State with SingleTickerProviderStateMixin { late TabController _tabController; - String? _selectedExchange; String? _selectedTicker; @override void initState() { super.initState(); + _selectedTicker = widget.selectedTicker; _tabController = TabController(length: 3, vsync: this); } @@ -48,11 +49,8 @@ class _AssetPageDesktopLayoutState extends State void _handleExchangeChanged(String newExchange, String newTicker) { setState(() { - _selectedExchange = newExchange; _selectedTicker = newTicker; }); - context.read().add( - LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker)); context.read().add(LoadAssetFundamentals(widget.isin, ticker: newTicker, forceRefresh: false)); context.read().add(LoadAssetTechnical(widget.isin, @@ -65,10 +63,8 @@ class _AssetPageDesktopLayoutState extends State } void _handleForceRefresh() { - context.read().add(LoadAssetHeader(widget.isin, - forceRefresh: true, - exchange: _selectedExchange, - ticker: _selectedTicker)); + context.read().add(LoadAssetFundamentals(widget.isin, + ticker: _selectedTicker, forceRefresh: true)); context.read().add(LoadAssetTechnical(widget.isin, ticker: _selectedTicker, forceRefresh: true)); context.read().add(LoadAssetTrades(widget.isin)); @@ -78,119 +74,100 @@ class _AssetPageDesktopLayoutState extends State Widget build(BuildContext context) { final theme = AppTheme.activePreset; - return BlocListener( - listener: (context, state) { - if (state is AssetHeaderLoaded && state.data != null) { - if (_selectedTicker == null) { - setState(() { - _selectedTicker = widget.selectedTicker; - }); - } - context.read().add(LoadAssetFundamentals( - widget.isin, - ticker: _selectedTicker, - forceRefresh: false)); - context.read().add(LoadAssetTechnical( - widget.isin, - ticker: _selectedTicker, - forceRefresh: false)); - } - }, - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. Hero Header - AssetHeroHeader( + return 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, - name: widget.name ?? widget.isin, - symbol: _selectedTicker ?? widget.selectedTicker, - onExchangeChanged: _handleExchangeChanged, - onForceRefresh: _handleForceRefresh, + symbol: _selectedTicker, + showChartOnly: true, + chartHeight: 460, ), + ), - // 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), ), - - 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(); - } - }, - ), - ], - ), + 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), - ], - ), + ), + 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 6a761b8..d8a647c 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 @@ -4,9 +4,6 @@ import '../../../../core/theme/app_theme.dart'; import '../../../favorites/cubit/favorites_cubit.dart'; import '../../bloc/fundamentals/asset_fundamentals_bloc.dart'; import '../../bloc/fundamentals/asset_fundamentals_event.dart'; -import '../../bloc/header/asset_header_bloc.dart'; -import '../../bloc/header/asset_header_event.dart'; -import '../../bloc/header/asset_header_state.dart'; import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_event.dart'; import '../../bloc/trades/asset_trades_bloc.dart'; @@ -21,8 +18,12 @@ class AssetPageMobileLayout extends StatefulWidget { final String? name; final String? selectedTicker; - const AssetPageMobileLayout( - {super.key, required this.isin, this.selectedTicker, this.name}); + const AssetPageMobileLayout({ + super.key, + required this.isin, + this.selectedTicker, + this.name, + }); @override State createState() => _AssetPageMobileLayoutState(); @@ -36,7 +37,7 @@ class _AssetPageMobileLayoutState extends State @override void initState() { super.initState(); - //_selectedTicker = widget.selectedTicker; + _selectedTicker = widget.selectedTicker; _tabController = TabController(length: 3, vsync: this); } @@ -48,11 +49,8 @@ class _AssetPageMobileLayoutState extends State void _handleExchangeChanged(String newExchange, String newTicker) { setState(() { - //_selectedExchange = newExchange; _selectedTicker = newTicker; }); - context.read().add( - LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker)); context.read().add(LoadAssetFundamentals(widget.isin, ticker: newTicker, forceRefresh: false)); context.read().add(LoadAssetTechnical(widget.isin, @@ -65,10 +63,8 @@ class _AssetPageMobileLayoutState extends State } void _handleForceRefresh() { - context.read().add(LoadAssetHeader(widget.isin, - forceRefresh: true, 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(LoadAssetFundamentals(widget.isin, + ticker: _selectedTicker, forceRefresh: true)); context.read().add(LoadAssetTechnical(widget.isin, ticker: _selectedTicker, forceRefresh: true)); context.read().add(LoadAssetTrades(widget.isin)); @@ -78,113 +74,102 @@ class _AssetPageMobileLayoutState extends State Widget build(BuildContext context) { final theme = AppTheme.activePreset; - return BlocListener( - listener: (context, state) { - if (state is AssetHeaderLoaded && state.data != null) { - if (_selectedTicker == null) { - setState(() { - _selectedTicker = widget.selectedTicker; - }); - } - context.read().add(LoadAssetFundamentals( - widget.isin, - ticker: _selectedTicker, - forceRefresh: false)); - context.read().add(LoadAssetTechnical( - widget.isin, - ticker: _selectedTicker, - forceRefresh: false)); - } - }, - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. Hero Header - AssetHeroHeader( + return 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. Interactive Chart + 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, - name: widget.name ?? widget.isin, - symbol: _selectedTicker ?? widget.selectedTicker, - onExchangeChanged: _handleExchangeChanged, - onForceRefresh: _handleForceRefresh, + symbol: _selectedTicker, + showChartOnly: true, + chartHeight: 320, ), + ), - // 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, - ), + const SizedBox(height: 8), + + // 3. Tabbed Detailed Analysis + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: theme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.glassBorder), ), - - 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(); - } - }, - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + controller: _tabController, + labelColor: theme.primaryColor, + unselectedLabelColor: theme.textMuted, + indicatorColor: theme.primaryColor, + dividerColor: theme.glassBorder, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 12), + tabs: const [ + Tab( + icon: Icon(Icons.analytics_outlined, size: 16), + text: 'FUNDAMENTALS'), + Tab( + icon: Icon(Icons.architecture_outlined, size: 16), + text: 'MUSTER & SIGNALE'), + Tab( + icon: Icon(Icons.candlestick_chart_outlined, size: 16), + 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), - ], - ), + ), + const SizedBox(height: 24), + ], ), ); } 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 015df5c..20655cb 100644 --- a/FinlyticApp/lib/features/asset_detail/views/tabs/fundamentals_tab.dart +++ b/FinlyticApp/lib/features/asset_detail/views/tabs/fundamentals_tab.dart @@ -3,14 +3,14 @@ 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/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'; +import '../../widgets/fundamentals/analyst_price_target_card.dart'; +import '../../widgets/fundamentals/fundamental_category_panels.dart'; +import '../../widgets/fundamentals/company_profile_section.dart'; -class FundamentalsTab extends StatefulWidget { +class FundamentalsTab extends StatelessWidget { final String isin; final String? symbol; final bool isEmbedded; @@ -22,22 +22,26 @@ class FundamentalsTab extends StatefulWidget { required this.isin, }); - @override - State createState() => _FundamentalsTabState(); -} - -class _FundamentalsTabState extends State { - String _sym = '\$'; - String _curCode = 'USD'; - - @override - void initState() { - super.initState(); + String _getCurrencySymbol(String? ticker) { + if (ticker == null || ticker.isEmpty) return '€'; + final t = ticker.toUpperCase(); + if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) { + return '€'; + } + if (t.endsWith('.L')) return '£'; + if (t.endsWith('.TO') || t.endsWith('.V')) return 'CA\$'; + return '\$'; } - @override - void didUpdateWidget(covariant FundamentalsTab oldWidget) { - super.didUpdateWidget(oldWidget); + String _getCurrencyCode(String? ticker) { + if (ticker == null || ticker.isEmpty) return 'EUR'; + final t = ticker.toUpperCase(); + if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) { + return 'EUR'; + } + if (t.endsWith('.L')) return 'GBP'; + if (t.endsWith('.TO') || t.endsWith('.V')) return 'CAD'; + return 'USD'; } @override @@ -60,7 +64,7 @@ class _FundamentalsTabState extends State { Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)), const SizedBox(height: 16), ElevatedButton.icon( - onPressed: () => context.read().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)), + onPressed: () => context.read().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)), icon: const Icon(Icons.refresh), label: const Text('Erneut versuchen'), ), @@ -70,89 +74,68 @@ 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(); - } + if (state is AssetFundamentalsLoaded && state.data != null) { + final data = state.data!; + final sym = _getCurrencySymbol(data.ticker); + final curCode = _getCurrencyCode(data.ticker); return SingleChildScrollView( - physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null, + physics: isEmbedded ? const NeverScrollableScrollPhysics() : null, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 1. Analyst Forecasts & Price Targets Header Card - _buildPriceTargetCard(data), + AnalystPriceTargetCard(data: data, currencySymbol: sym), const SizedBox(height: 20), // 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends) - _buildCategoryPanels(data), + FundamentalCategoryPanels(data: data, currencySymbol: sym, currencyCode: curCode), const SizedBox(height: 20), // 3. Company Description & Detailed Executive Board _buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined), const SizedBox(height: 12), - _buildProfileSection(data), + CompanyProfileSection(data: data, currencySymbol: sym), ], ), ); } - return _buildEmptyState(); + return _buildEmptyState(context); }, ); } - Widget _buildPriceTargetCard(FundamentalDataModel data) { - final rating = data.consensusRating ?? 'N/A'; - final targetMean = data.priceTargetMean; - final targetLow = data.priceTargetLow; - final targetHigh = data.priceTargetHigh; - - return GlassContainer( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22), - const SizedBox(width: 8), - const Text('Analysten-Konsens & Kursziele', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), - ], - ), - StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed), - _buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald), - _buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan), - ], - ), - ], - ), + Widget _buildSectionHeader(String title, IconData icon) { + return Row( + children: [ + Icon(icon, color: AppTheme.primaryEmerald, size: 20), + const SizedBox(width: 8), + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ], ); } - Widget _buildTargetStat(String title, String val, Color col) { - return Column( - children: [ - Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), - const SizedBox(height: 4), - Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)), - ], + Widget _buildEmptyState(BuildContext context) { + return Center( + child: GlassContainer( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.analytics_outlined, color: AppTheme.textMuted, size: 48), + const SizedBox(height: 12), + const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70)), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => context.read().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)), + icon: const Icon(Icons.refresh), + label: const Text('Aktualisieren'), + ), + ], + ), + ), ); } @@ -188,16 +171,13 @@ class _FundamentalsTabState extends State { } return SingleChildScrollView( - physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null, + physics: isEmbedded ? const NeverScrollableScrollPhysics() : null, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Price Target Card Shimmer const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16), const SizedBox(height: 20), - - // 3 Category Panels Shimmer if (isDesktop) Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -234,9 +214,7 @@ class _FundamentalsTabState extends State { 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), @@ -244,454 +222,4 @@ class _FundamentalsTabState extends State { ), ); } - - Widget _buildProfileSection(FundamentalDataModel data) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GlassContainer( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (data.sector != null || data.industry != null || data.country != null) ...[ - Row( - children: [ - if (data.sector != null) ...[ - _buildProfileBadge(data.sector!, Icons.category_outlined), - const SizedBox(width: 8), - ], - if (data.country != null) - _buildProfileBadge(data.country!, Icons.place_outlined), - ], - ), - const SizedBox(height: 12), - ], - Text( - data.businessSummary != null && data.businessSummary!.isNotEmpty - ? data.businessSummary! - : 'Keine Beschreibung für dieses Asset verfügbar.', - style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13), - ), - if (data.employees != null) ...[ - const SizedBox(height: 12), - Row( - children: [ - Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted), - const SizedBox(width: 6), - Text( - 'Mitarbeiter: ${data.employees}', - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - ), - ], - ), - ], - ], - ), - ), - if (data.executives.isNotEmpty) ...[ - const SizedBox(height: 16), - const Text('Führungskräfte (Board)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14)), - const SizedBox(height: 8), - ...data.executives.take(5).map((e) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: GlassContainer( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryEmerald.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon(Icons.person_outline, color: AppTheme.primaryEmerald, size: 18), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(e.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), - Text(e.title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), - ], - ), - ), - if (e.compensation != null && e.compensation! > 0) - Text( - _formatNumber(e.compensation), - style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12), - ), - ], - ), - ), - )), - ], - ], - ); - } - - Widget _buildProfileBadge(String label, IconData icon) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 12, color: AppTheme.primaryEmerald), - const SizedBox(width: 6), - Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11)), - ], - ), - ); - } - - Widget _buildEmptyState() { - return Center( - child: GlassContainer( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.insert_chart_outlined, color: AppTheme.textMuted, size: 48), - const SizedBox(height: 12), - const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.bold)), - const SizedBox(height: 6), - Text('Für dieses Asset wurden noch keine Bilanz- oder Bewertungskennzahlen erfasst.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: () => context.read().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)), - icon: const Icon(Icons.download), - label: const Text('Daten von Backend abrufen'), - ), - ], - ), - ), - ); - } - - Widget _buildSectionHeader(String title, IconData icon) { - return Row( - children: [ - Icon(icon, color: AppTheme.primaryEmerald, size: 20), - const SizedBox(width: 8), - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), - ], - ); - } - - 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(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( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - ), - const SizedBox(width: 4), - Icon(Icons.info_outline, size: 11, color: AppTheme.textMuted.withValues(alpha: 0.6)), - ], - ), - 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, - ), - ), - ], - ), - ), - ); - } - - String _fmtMultiple(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)}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'; - // 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()); - if (n == null || n == 0) return 'N/A'; - return '$_sym${n.toStringAsFixed(2)}'; - } - - String _fmtDate(dynamic val) { - if (val == null) return 'N/A'; - final dt = DateTime.tryParse(val.toString()); - return dt != null ? '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}' : val.toString(); - } - - String _formatNumber(dynamic val) { - if (val == null) return 'N/A'; - final num? n = val is num ? val : num.tryParse(val.toString()); - if (n == null) return val.toString(); - - final isNegative = n < 0; - final absVal = n.abs(); - final prefix = isNegative ? '-$_sym' : _sym; - - if (absVal >= 1e12) { - 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(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/trades_tab.dart b/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart index 59d72a4..2318111 100644 --- a/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart +++ b/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart @@ -4,13 +4,15 @@ 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'; - -import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart'; -import 'package:finlytic_app/features/trades/widgets/trade_execution_dialog.dart'; +import '../../../trades/models/trade_model.dart'; +import '../../../trades/widgets/trade_execution_dialog.dart'; import '../../bloc/trades/asset_trades_bloc.dart'; import '../../bloc/trades/asset_trades_event.dart'; import '../../bloc/trades/asset_trades_state.dart'; +import '../../widgets/trades/live_trade_settings_dialog.dart'; +import '../../widgets/trades/manual_analysis_dialog.dart'; +import '../../widgets/trades/close_trade_dialog.dart'; +import '../../widgets/trades/asset_trade_item_card.dart'; class TradesTab extends StatefulWidget { final String symbol; @@ -22,13 +24,13 @@ class TradesTab extends StatefulWidget { class _TradesTabState extends State { bool _justTriggeredAnalysis = false; - - // Settings State - double _defaultPositionSize = 2500.0; - double _defaultLeverage = 5.0; - double _defaultRiskScore = 50.0; - double _defaultOrderFee = 1.0; - bool _autoAcceptSignals = false; + LiveTradeSettings _settings = const LiveTradeSettings( + defaultPositionSize: 2500.0, + defaultLeverage: 5.0, + defaultRiskScore: 50.0, + defaultOrderFee: 1.0, + autoAcceptSignals: false, + ); @override void initState() { @@ -36,413 +38,9 @@ class _TradesTabState extends State { context.read().add(LoadAssetTrades(widget.symbol)); } - void _showLiveTradeSettingsDialog(BuildContext context) { - double tempPos = _defaultPositionSize; - double tempLev = _defaultLeverage; - double tempRisk = _defaultRiskScore; - double tempFee = _defaultOrderFee; - bool tempAuto = _autoAcceptSignals; - - final posController = TextEditingController(text: tempPos.toStringAsFixed(0)); - final levController = TextEditingController(text: tempLev.toStringAsFixed(1)); - final feeController = TextEditingController(text: tempFee.toStringAsFixed(2)); - - showDialog( - context: context, - builder: (dialogContext) { - return StatefulBuilder( - builder: (builderContext, setModalState) { - return AlertDialog( - backgroundColor: AppTheme.cardSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: AppTheme.glassBorder), - ), - title: Row( - children: [ - Icon(Icons.settings, color: AppTheme.accentCyan, size: 22), - const SizedBox(width: 8), - Expanded( - child: Text('Live Trade Einstellungen', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - ), - ], - ), - content: SizedBox( - width: 440, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Standard Trade-Vorgaben für Ihr Depot:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), - const SizedBox(height: 16), - - Row( - children: [ - Expanded( - child: TextField( - controller: posController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Standard Investment (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - onChanged: (v) => tempPos = double.tryParse(v) ?? tempPos, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: levController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Standard Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - onChanged: (v) => tempLev = double.tryParse(v) ?? tempLev, - ), - ), - ], - ), - const SizedBox(height: 12), - - TextField( - controller: feeController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Standard Ordergebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - onChanged: (v) => tempFee = double.tryParse(v) ?? tempFee, - ), - const SizedBox(height: 14), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Standard Risiko-Toleranz:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - Text('${tempRisk.toInt()}/100', style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 13)), - ], - ), - Slider( - value: tempRisk, - min: 0, - max: 100, - divisions: 100, - activeColor: AppTheme.primaryEmerald, - inactiveColor: AppTheme.glassSurface, - onChanged: (val) => setModalState(() => tempRisk = val), - ), - const SizedBox(height: 10), - - SwitchListTile( - value: tempAuto, - activeThumbColor: AppTheme.primaryEmerald, - title: const Text('KI-Signale automatisch annehmen', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)), - subtitle: Text('Führt eingehende Signale direkt im Depot aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), - onChanged: (val) => setModalState(() => tempAuto = val), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), - ), - ElevatedButton.icon( - onPressed: () { - setState(() { - _defaultPositionSize = tempPos; - _defaultLeverage = tempLev; - _defaultRiskScore = tempRisk; - _defaultOrderFee = tempFee; - _autoAcceptSignals = tempAuto; - }); - Navigator.pop(dialogContext); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Live Trade Einstellungen gespeichert.'), - backgroundColor: AppTheme.primaryEmerald, - behavior: SnackBarBehavior.floating, - ), - ); - }, - icon: const Icon(Icons.save), - label: const Text('Einstellungen Speichern'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accentCyan, - foregroundColor: Colors.black, - ), - ), - ], - ); - }, - ); - }, - ); - } - - void _showCloseTradeDialog(BuildContext context, TradeModel trade) { - final tradesBloc = context.read(); - final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice; - final exitController = TextEditingController(text: entry.toStringAsFixed(2)); - - 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(Icons.flag_outlined, color: AppTheme.accentRed, size: 22), - const SizedBox(width: 8), - Expanded( - child: Text('Trade Position Schließen', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - ), - ], - ), - content: SizedBox( - width: 400, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : widget.symbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), - const SizedBox(height: 16), - TextField( - controller: exitController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration( - labelText: 'Tatsächlicher Ausstiegskurs (€)', - hintText: 'Z.B. 105.50', - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), - ), - ElevatedButton.icon( - onPressed: () { - final exitPrice = double.tryParse(exitController.text) ?? entry; - final tradeId = trade.id; - if (tradeId.isNotEmpty) { - tradesBloc.add(CloseTradeEvent(tradeId, widget.symbol, exitPrice)); - } - Navigator.pop(dialogContext); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Trade $tradeId geschlossen zu €${exitPrice.toStringAsFixed(2)}.'), - backgroundColor: AppTheme.accentRed, - behavior: SnackBarBehavior.floating, - ), - ); - }, - icon: const Icon(Icons.check_circle), - label: const Text('Position Schließen'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accentRed, - foregroundColor: Colors.white, - ), - ), - ], - ); - }, - ); - } - - - void _showAnalysisParametersDialog(BuildContext context) { - final tradesBloc = context.read(); - - final minTimeframeController = TextEditingController(text: '1'); - final maxTimeframeController = TextEditingController(text: '14'); - double riskScore = _defaultRiskScore; - String timeframeUnit = 'Tage'; - String instrumentType = 'Aktie / ETF (Direktinvestment)'; - final notesController = TextEditingController(); - - showDialog( - context: context, - builder: (dialogContext) { - return StatefulBuilder( - builder: (builderContext, setModalState) { - return AlertDialog( - backgroundColor: AppTheme.cardSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: AppTheme.glassBorder), - ), - title: Row( - children: [ - Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22), - const SizedBox(width: 8), - Expanded( - child: Text('KI-Analyse Konfigurieren', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - ), - ], - ), - content: SizedBox( - width: 480, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Asset / ISIN: ${widget.symbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), - const SizedBox(height: 16), - - // 1. Haltedauer von - bis mit Einheit - const Text('Geplante Haltedauer:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: minTimeframeController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: maxTimeframeController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: DropdownButtonFormField( - initialValue: timeframeUnit, - dropdownColor: AppTheme.cardSurface, - decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - items: const [ - DropdownMenuItem(value: 'Stunden', child: Text('Stunden')), - DropdownMenuItem(value: 'Tage', child: Text('Tage')), - DropdownMenuItem(value: 'Wochen', child: Text('Wochen')), - DropdownMenuItem(value: 'Monate', child: Text('Monate')), - ], - onChanged: (val) { - if (val != null) setModalState(() => timeframeUnit = val); - }, - ), - ), - ], - ), - const SizedBox(height: 16), - - // 2. Risikobereitschaft 0-100 Slider - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - Text( - '${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})', - style: TextStyle(color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), fontWeight: FontWeight.bold, fontSize: 13), - ), - ], - ), - Slider( - value: riskScore, - min: 0, - max: 100, - divisions: 100, - activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), - inactiveColor: AppTheme.glassSurface, - onChanged: (val) => setModalState(() => riskScore = val), - ), - const SizedBox(height: 12), - - // 3. Instrumententyp (Trade Republic typisch) - const Text('Instrumententyp (Trade Republic):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 6), - DropdownButtonFormField( - initialValue: instrumentType, - dropdownColor: AppTheme.cardSurface, - decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)), - items: const [ - DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')), - DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')), - DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo)')), - DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')), - DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')), - ], - onChanged: (val) { - if (val != null) setModalState(() => instrumentType = val); - }, - ), - const SizedBox(height: 16), - - // 4. Anmerkung für die KI - const Text('Anmerkung für die KI:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 6), - TextField( - controller: notesController, - maxLines: 3, - decoration: const InputDecoration( - hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...', - ), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), - ), - ElevatedButton.icon( - onPressed: () { - final payload = ManualAnalysisRequestDto( - isin: widget.symbol, - symbol: widget.symbol, - riskScore: riskScore.toInt(), - minTimeframeValue: int.tryParse(minTimeframeController.text) ?? 1, - maxTimeframeValue: int.tryParse(maxTimeframeController.text) ?? 14, - timeframeUnit: timeframeUnit, - instrumentType: instrumentType, - userNotes: notesController.text, - headline: 'Manuelle KI-Analyse für ${widget.symbol}', - ); - - setState(() { - _justTriggeredAnalysis = true; - }); - - tradesBloc.add(TriggerManualAnalysis(widget.symbol, payload: payload)); - Navigator.pop(dialogContext); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'), - backgroundColor: AppTheme.accentCyan, - behavior: SnackBarBehavior.floating, - ), - ); - }, - icon: const Icon(Icons.flash_on), - label: const Text('Analyse Jetzt Ausführen'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accentCyan, - foregroundColor: Colors.black, - ), - ), - ], - ); - }, - ); - }, - ); - } - void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) { final tradesBloc = context.read(); - + TradeExecutionDialog.show( context, trade: trade, @@ -453,9 +51,7 @@ class _TradesTabState extends State { final tId = trade.id; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(isActive - ? 'Einstellungen für Trade $tId gespeichert!' - : 'Trade $tId angenommen & Position eröffnet!'), + content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'), backgroundColor: AppTheme.primaryEmerald, behavior: SnackBarBehavior.floating, ), @@ -497,7 +93,6 @@ class _TradesTabState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Action Button & Settings Card GlassContainer( padding: const EdgeInsets.all(16), child: Column( @@ -525,7 +120,24 @@ class _TradesTabState extends State { children: [ Expanded( child: ElevatedButton.icon( - onPressed: () => _showAnalysisParametersDialog(context), + onPressed: () { + ManualAnalysisDialog.show( + context, + symbol: widget.symbol, + initialRiskScore: _settings.defaultRiskScore, + onTrigger: (payload) { + setState(() => _justTriggeredAnalysis = true); + context.read().add(TriggerManualAnalysis(widget.symbol, payload: payload)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'), + backgroundColor: AppTheme.accentCyan, + behavior: SnackBarBehavior.floating, + ), + ); + }, + ); + }, icon: const Icon(Icons.auto_awesome, size: 18), label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), style: ElevatedButton.styleFrom( @@ -538,7 +150,13 @@ class _TradesTabState extends State { ), const SizedBox(width: 10), IconButton.filledTonal( - onPressed: () => _showLiveTradeSettingsDialog(context), + onPressed: () { + LiveTradeSettingsDialog.show( + context, + currentSettings: _settings, + onSave: (newSettings) => setState(() => _settings = newSettings), + ); + }, icon: const Icon(Icons.settings, color: Colors.white), tooltip: 'Live Trade Einstellungen', style: IconButton.styleFrom( @@ -552,7 +170,6 @@ class _TradesTabState extends State { ), ), const SizedBox(height: 20), - if (state is AssetTradesLoading) _buildTradesShimmer(context) else if (state is AssetTradesError) @@ -618,313 +235,38 @@ class _TradesTabState extends State { itemCount: trades.length, itemBuilder: (context, index) { final trade = trades[index]; - return _buildRichTradeCard(trade); + final s = trade.status.toUpperCase(); + final isActive = s == 'ACTIVE'; + + return AssetTradeItemCard( + trade: trade, + defaultSymbol: widget.symbol, + onAccept: () => _showEditTradeExecutionDialog(context, trade), + onSettings: () => _showEditTradeExecutionDialog(context, trade, isActive: true), + onClose: isActive + ? () { + CloseTradeDialog.show( + context, + trade: trade, + defaultSymbol: widget.symbol, + onClose: (dto) { + final isinVal = trade.isin.isNotEmpty ? trade.isin : widget.symbol; + context.read().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Trade ${trade.id} geschlossen! Ausstiegskurs: €${dto.userExitPrice.toStringAsFixed(2)}'), + backgroundColor: AppTheme.primaryEmerald, + behavior: SnackBarBehavior.floating, + ), + ); + }, + ); + } + : null, + ); }, ), ], ); } - - Widget _buildRichTradeCard(TradeModel trade) { - final isin = trade.isin.isNotEmpty ? trade.isin : widget.symbol; - final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase(); - final status = trade.status.toUpperCase(); - final isBuy = side == 'BUY' || side == 'LONG'; - final isActive = status == 'ACTIVE'; - final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed; - - // AI Execution Plan N8N values - 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 > 0 && stopLoss > 0 && entryPrice > 0) ? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2) : null; - final maxLeverage = trade.maxLeverage; - - // Real User Execution Values - final actualEntry = trade.actualEntryPrice; - final posSize = trade.positionSize; - final levUsed = trade.leverageUsed; - final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0; - final entryFee = trade.entryFee; - final exitFee = trade.exitFee; - - // Rationale strings - final reasoning = trade.reasoning; - final techRationale = trade.technicalRationale; - final fundRationale = trade.fundamentalRationale; - final riskWarning = trade.riskWarning; - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: GlassContainer( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Row: Side, Status, Instrument, Action Buttons cv - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - StatusBadge(label: side, color: sideColor), - const SizedBox(width: 8), - StatusBadge(label: status, color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted)), - 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: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)), - ), - ], - ), - Row( - children: [ - if (isActive) ...[ - ElevatedButton.icon( - onPressed: () => _showCloseTradeDialog(context, trade), - icon: const Icon(Icons.flag_outlined, size: 14), - label: const Text('Schließen'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accentRed, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - const SizedBox(width: 8), - IconButton( - onPressed: () => _showEditTradeExecutionDialog(context, trade, isActive: true), - icon: const Icon(Icons.settings, size: 16, color: Colors.white), - style: IconButton.styleFrom( - backgroundColor: AppTheme.glassSurface, - padding: const EdgeInsets.all(8), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ] else if (status == 'PROPOSED' || status == 'PENDING') ...[ - ElevatedButton.icon( - onPressed: () => _showEditTradeExecutionDialog(context, trade), - icon: const Icon(Icons.check_circle, size: 14), - label: const Text('Trade Annehmen'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryEmerald, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ], - ], - ), - ], - ), - const SizedBox(height: 12), - - // Asset ID & Timeframe Subheader - Text('${trade.companyName.isNotEmpty ? trade.companyName : widget.symbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)), - const SizedBox(height: 12), - - // AI Execution Targets Grid (Entry Zone, SL, TP, CRV, MaxLeverage) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.glassSurface, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.glassBorder), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '€${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '€${_fmt(entryPrice)}', Colors.white), - _buildTradeStat('Stop-Loss', '€${_fmt(stopLoss)}', AppTheme.accentRed), - _buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '€${_fmt(t)}').join(' / ') : '€${_fmt(takeProfit)}', AppTheme.primaryEmerald), - ], - ), - if (crv != null || maxLeverage > 0) ...[ - const Divider(color: Colors.white12, height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan), - if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent), - ], - ), - ], - ], - ), - ), - - // Real User Execution Data Section (Actual Entry, Position Size, Leverage Used, Fees, Quantity) - if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.primaryEmerald.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald), - const SizedBox(width: 6), - const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildTradeStat('Tatsächl. Einstieg', '€${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white), - _buildTradeStat('Investition', posSize > 0 ? '€${_fmt(posSize)}' : 'N/A', Colors.white), - _buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald), - _buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70), - ], - ), - if (entryFee > 0 || exitFee > 0) ...[ - const SizedBox(height: 6), - Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), - ], - ], - ), - ), - ], - - // Closed Trade Outcome & Performance Section - if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[ - const SizedBox(height: 12), - Builder( - builder: (context) { - final pnlVal = trade.calculatedPnlAbs; - final pnlPctVal = trade.calculatedPnlPct; - final isWin = pnlVal >= 0; - final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed; - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - isWin ? Icons.trending_up : Icons.trending_down, - size: 16, - color: color, - ), - const SizedBox(width: 6), - const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), - ], - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildTradeStat('Ausstiegskurs', 'N/A', Colors.white), - _buildTradeStat( - 'Realisierter PnL (€)', - '${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}', - color, - ), - _buildTradeStat( - 'Rendite (%)', - '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', - pnlPctVal >= 0 ? AppTheme.primaryEmerald : AppTheme.accentRed, - ), - ], - ), - ], - ), - ); - }, - ), - ], - - // AI Rationale & Warnings - if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[ - const SizedBox(height: 12), - ExpansionTile( - tilePadding: EdgeInsets.zero, - childrenPadding: EdgeInsets.zero, - dense: true, - title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)), - children: [ - if (reasoning.isNotEmpty) ...[ - _buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70), - const SizedBox(height: 8), - ], - if (techRationale.isNotEmpty) ...[ - _buildRationaleBlock('Technische Analyse', techRationale, Colors.white70), - const SizedBox(height: 8), - ], - if (fundRationale.isNotEmpty) ...[ - _buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70), - const SizedBox(height: 8), - ], - if (riskWarning.isNotEmpty) - _buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed), - ], - ), - ], - ], - ), - ), - ); - } - - Widget _buildTradeStat(String title, String val, Color col) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), - const SizedBox(height: 2), - Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)), - ], - ); - } - - Widget _buildRationaleBlock(String title, String text, Color col) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)), - const SizedBox(height: 2), - Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)), - ], - ); - } - - String _fmt(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) : val.toString(); - } - - } diff --git a/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/analyst_price_target_card.dart b/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/analyst_price_target_card.dart new file mode 100644 index 0000000..9b53e4a --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/analyst_price_target_card.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/glass_container.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../../models/fundamental_data_model.dart'; + +class AnalystPriceTargetCard extends StatelessWidget { + final FundamentalDataModel data; + final String currencySymbol; + + const AnalystPriceTargetCard({ + super.key, + required this.data, + this.currencySymbol = '\$', + }); + + String _fmtCurrency(double? val) { + if (val == null) return 'N/A'; + return '$currencySymbol${val.toStringAsFixed(2)}'; + } + + @override + Widget build(BuildContext context) { + final rating = data.consensusRating ?? 'N/A'; + final targetMean = data.priceTargetMean; + final targetLow = data.priceTargetLow; + final targetHigh = data.priceTargetHigh; + + return GlassContainer( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22), + const SizedBox(width: 8), + const Text( + 'Analysten-Konsens & Kursziele', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + ], + ), + StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed), + _buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald), + _buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan), + ], + ), + ], + ), + ); + } + + Widget _buildTargetStat(String title, String val, Color col) { + return Column( + children: [ + Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + const SizedBox(height: 4), + Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)), + ], + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/company_profile_section.dart b/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/company_profile_section.dart new file mode 100644 index 0000000..5a6c00b --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/company_profile_section.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/glass_container.dart'; +import '../../models/fundamental_data_model.dart'; + +class CompanyProfileSection extends StatelessWidget { + final FundamentalDataModel data; + final String currencySymbol; + + const CompanyProfileSection({ + super.key, + required this.data, + this.currencySymbol = '\$', + }); + + String _fmtCompensation(double? val) { + if (val == null || val <= 0) return '---'; + if (val >= 1e6) return '$currencySymbol${(val / 1e6).toStringAsFixed(2)}M'; + if (val >= 1e3) return '$currencySymbol${(val / 1e3).toStringAsFixed(0)}K'; + return '$currencySymbol${val.toStringAsFixed(0)}'; + } + + String _formatExecutivePayment(CompanyExecutiveModel exec) { + if (exec.compensation != null && exec.compensation! > 0) { + return _fmtCompensation(exec.compensation); + } + if (exec.payment != null && exec.payment!.isNotEmpty) { + final p = exec.payment!.trim(); + if (p.startsWith(currencySymbol) || p.startsWith('€') || p.startsWith(r'$')) { + return p; + } + final numeric = double.tryParse(p); + if (numeric != null && numeric > 0) { + return _fmtCompensation(numeric); + } + return '$currencySymbol$p'; + } + return '---'; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GlassContainer( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (data.sector != null || data.industry != null || data.country != null) ...[ + Row( + children: [ + if (data.sector != null) ...[ + _buildProfileBadge(data.sector!, Icons.category_outlined), + const SizedBox(width: 8), + ], + if (data.country != null) + _buildProfileBadge(data.country!, Icons.place_outlined), + ], + ), + const SizedBox(height: 12), + ], + Text( + data.businessSummary != null && data.businessSummary!.isNotEmpty + ? data.businessSummary! + : 'Keine Beschreibung für dieses Asset verfügbar.', + style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13), + ), + if (data.employees != null) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted), + const SizedBox(width: 6), + Text( + 'Vollzeitbeschäftigte: ${data.employees}', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + ], + ), + ], + ], + ), + ), + if (data.executives.isNotEmpty) ...[ + const SizedBox(height: 16), + const Text( + 'Führungskräfte & Vorstand', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 8), + GlassContainer( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Column( + children: [ + for (int i = 0; i < data.executives.length; i++) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.executives[i].name, + style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 13), + ), + const SizedBox(height: 2), + Text( + data.executives[i].title, + style: TextStyle(color: AppTheme.textMuted, fontSize: 11), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Builder( + builder: (context) { + final payStr = _formatExecutivePayment(data.executives[i]); + if (payStr == '---') return const SizedBox.shrink(); + return Text( + payStr, + style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 12), + ); + }, + ), + ], + ), + ), + if (i < data.executives.length - 1) const Divider(color: Colors.white10, height: 1), + ], + ], + ), + ), + ], + ], + ); + } + + Widget _buildProfileBadge(String text, IconData icon) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.primaryEmerald), + const SizedBox(width: 6), + Text(text, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500)), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/fundamental_category_panels.dart b/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/fundamental_category_panels.dart new file mode 100644 index 0000000..e382868 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/fundamentals/fundamental_category_panels.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/glass_container.dart'; +import '../../models/fundamental_data_model.dart'; +import '../../utils/metric_explanations.dart'; + +class _MetricRowItem { + final String label; + final String value; + const _MetricRowItem(this.label, this.value); +} + +class FundamentalCategoryPanels extends StatelessWidget { + final FundamentalDataModel data; + final String currencySymbol; + final String currencyCode; + + const FundamentalCategoryPanels({ + super.key, + required this.data, + this.currencySymbol = '\$', + this.currencyCode = 'USD', + }); + + String _formatNumber(double? number) { + if (number == null) return 'N/A'; + final abs = number.abs(); + final sign = number < 0 ? '-' : ''; + if (abs >= 1e12) return '$sign$currencySymbol${(abs / 1e12).toStringAsFixed(2)} Tsd. Mrd. $currencyCode'; + if (abs >= 1e9) return '$sign$currencySymbol${(abs / 1e9).toStringAsFixed(2)} Mrd. $currencyCode'; + if (abs >= 1e6) return '$sign$currencySymbol${(abs / 1e6).toStringAsFixed(2)} Mio. $currencyCode'; + return '$sign$currencySymbol${NumberFormat("#,##0.00", "de_DE").format(abs)} $currencyCode'; + } + + String _fmtCurrency(double? val) { + if (val == null) return 'N/A'; + return '$currencySymbol${val.toStringAsFixed(2)}'; + } + + String _fmtMultiple(double? val) { + if (val == null) return 'N/A'; + return '${val.toStringAsFixed(2)}x'; + } + + String _fmtPercent(double? val) { + if (val == null) return 'N/A'; + final p = (val.abs() <= 1.0 && val != 0.0) ? val * 100.0 : val; + return '${p.toStringAsFixed(2)}%'; + } + + String _fmtDebtToEquity(double? val) { + if (val == null) return 'N/A'; + final p = val > 10.0 ? val : val * 100.0; + return '${p.toStringAsFixed(1)}%'; + } + + String _fmtDate(String? raw) { + if (raw == null || raw.isEmpty) return 'N/A'; + final dt = DateTime.tryParse(raw); + if (dt == null) return raw; + return DateFormat('dd.MM.yyyy').format(dt); + } + + @override + Widget build(BuildContext context) { + 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( + context: context, + title: 'Bewertungskennzahlen & Multiples', + icon: Icons.analytics_outlined, + items: valuationItems, + ); + + final panel2 = _buildCategoryPanel( + context: context, + title: 'Rentabilität & Finanzen', + icon: Icons.account_balance_outlined, + items: profitabilityItems, + ); + + final panel3 = _buildCategoryPanel( + context: context, + 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 BuildContext context, + 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), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 10), + const Divider(color: Colors.white10, height: 1), + const SizedBox(height: 4), + for (int i = 0; i < items.length; i++) ...[ + _buildMetricTile(context, items[i].label, items[i].value, isEven: i.isEven), + ], + ], + ), + ); + } + + Widget _buildMetricTile(BuildContext context, String label, String value, {bool isEven = false}) { + final hasExplanation = MetricExplanations.hasExplanation(label); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + if (hasExplanation) ...[ + const SizedBox(width: 4), + InkWell( + onTap: () => MetricExplanations.showModal(context, label), + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted), + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 12), + ), + ], + ), + ); + } +} 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 487ff35..c387fb9 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 @@ -3,11 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/theme/app_theme.dart'; 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/fundamentals/asset_fundamentals_bloc.dart'; +import '../../bloc/fundamentals/asset_fundamentals_state.dart'; import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_state.dart'; -import '../../models/asset_model.dart'; +import '../../models/fundamental_data_model.dart'; import 'package:url_launcher/url_launcher.dart'; class AssetHeroHeader extends StatelessWidget { @@ -20,35 +20,31 @@ class AssetHeroHeader extends StatelessWidget { const AssetHeroHeader({ super.key, this.onExchangeChanged, - this.onForceRefresh, required this.isin, required this.name, this.symbol, + this.onForceRefresh, + required this.isin, + required this.name, + this.symbol, }); @override Widget build(BuildContext context) { final theme = AppTheme.activePreset; - return BlocBuilder( - builder: (context, state) { - double? price; - String currency = 'EUR'; - List tickerOptions = [ - AssetTickerOption(ticker: 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: 0.0) + return BlocBuilder( + builder: (context, fundState) { + String displayName = name; + final String? logoUrl = isin.isNotEmpty ? '/api/v1/logo/$isin' : null; + List tickerOptions = [ + TickerModel(ticker: symbol ?? 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: null) ]; - AssetModel? asset; - if (state is AssetHeaderLoaded) { - asset = state.data; - } else if (state is AssetHeaderLoading) { - asset = state.previousData; - } - - if (asset != null) { - //name = asset.name.isNotEmpty ? asset.name : symbol; - currency = asset.currency.isNotEmpty ? asset.currency : 'EUR'; - price = asset.currentPrice; - - if (asset.tickers.isNotEmpty) { - tickerOptions = asset.tickers; + if (fundState is AssetFundamentalsLoaded && fundState.data != null) { + final data = fundState.data!; + if (data.companyName.isNotEmpty) { + displayName = data.companyName; + } + if (data.availableTickers.isNotEmpty) { + tickerOptions = data.availableTickers; } } @@ -88,14 +84,14 @@ class AssetHeroHeader extends StatelessWidget { ), const SizedBox(width: 8), ], - AssetLogoWidget(symbolOrName: isin, imageUrl: asset?.image, size: 48), + AssetLogoWidget(symbolOrName: isin, imageUrl: logoUrl, size: 48), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( - name, + displayName, style: TextStyle( fontSize: 24, fontWeight: FontWeight.w900, @@ -138,7 +134,7 @@ class AssetHeroHeader extends StatelessWidget { onPressed: onForceRefresh, ), const SizedBox(width: 8), - FavoriteStarButton(symbol: symbol, identifier: isin, name: name), + FavoriteStarButton(symbol: symbol, identifier: isin, name: displayName), ], ), ], @@ -150,10 +146,8 @@ class AssetHeroHeader extends StatelessWidget { children: [ BlocBuilder( builder: (context, taState) { - double? livePrice = price; - String liveCurrency = selectedOption.tradingCurrency.isNotEmpty - ? selectedOption.tradingCurrency - : currency; + double? livePrice = selectedOption.currentPrice; + String liveCurrency = selectedOption.tradingCurrency ?? 'EUR'; if (taState is AssetTechnicalLoaded && taState.data != null) { if (taState.data!.candles.isNotEmpty) { @@ -219,12 +213,12 @@ class AssetHeroHeader extends StatelessWidget { (t) => t.ticker == newTicker, orElse: () => tickerOptions.first, ); - onExchangeChanged!(opt.exchange, opt.ticker); + onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker); } }, itemBuilder: (context) { return tickerOptions.map((opt) { - final ex = opt.exchange; + final ex = opt.exchange ?? 'Unknown'; final tick = opt.ticker; final label = '$tick ($ex)'; final isSelected = tick == symbol || ex == symbol; @@ -263,7 +257,7 @@ class AssetHeroHeader extends StatelessWidget { Icon(Icons.business, size: 14, color: theme.accentColor), const SizedBox(width: 6), Text( - '${selectedOption.ticker} (${selectedOption.exchange})', + '${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})', style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, @@ -285,3 +279,4 @@ class AssetHeroHeader extends StatelessWidget { ); } } + diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart new file mode 100644 index 0000000..a887724 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/glass_container.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../../../trades/models/trade_model.dart'; + +class AssetTradeItemCard extends StatelessWidget { + final TradeModel trade; + final String defaultSymbol; + final VoidCallback? onAccept; + final VoidCallback? onSettings; + final VoidCallback? onClose; + + const AssetTradeItemCard({ + super.key, + required this.trade, + required this.defaultSymbol, + this.onAccept, + this.onSettings, + this.onClose, + }); + + String _fmt(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) : val.toString(); + } + + @override + Widget build(BuildContext context) { + final isin = trade.isin.isNotEmpty ? trade.isin : defaultSymbol; + final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase(); + final status = trade.status.toUpperCase(); + final isBuy = side == 'BUY' || side == 'LONG'; + final isActive = status == 'ACTIVE'; + final sideColor = isBuy ? 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 > 0 && stopLoss > 0 && entryPrice > 0) + ? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2) + : null; + final maxLeverage = trade.maxLeverage; + + final actualEntry = trade.actualEntryPrice; + final posSize = trade.positionSize; + final levUsed = trade.leverageUsed; + final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0; + final entryFee = trade.entryFee; + final exitFee = trade.exitFee; + + final reasoning = trade.reasoning; + final techRationale = trade.technicalRationale; + final fundRationale = trade.fundamentalRationale; + final riskWarning = trade.riskWarning; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GlassContainer( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + StatusBadge(label: side, color: sideColor), + const SizedBox(width: 8), + StatusBadge( + label: status, + color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted), + ), + 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: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ), + Row( + children: [ + if (isActive) ...[ + if (onClose != null) + ElevatedButton.icon( + onPressed: onClose, + icon: const Icon(Icons.flag_outlined, size: 14), + label: const Text('Schließen'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentRed, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 8), + if (onSettings != null) + IconButton( + onPressed: onSettings, + icon: const Icon(Icons.settings, size: 16, color: Colors.white), + style: IconButton.styleFrom( + backgroundColor: AppTheme.glassSurface, + padding: const EdgeInsets.all(8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ] else if (status == 'PROPOSED' || status == 'PENDING') ...[ + if (onAccept != null) + ElevatedButton.icon( + onPressed: onAccept, + icon: const Icon(Icons.check_circle, size: 14), + label: const Text('Trade Annehmen'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryEmerald, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ], + ), + ], + ), + const SizedBox(height: 12), + Text( + '${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '€${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '€${_fmt(entryPrice)}', Colors.white), + _buildTradeStat('Stop-Loss', '€${_fmt(stopLoss)}', AppTheme.accentRed), + _buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '€${_fmt(t)}').join(' / ') : '€${_fmt(takeProfit)}', AppTheme.primaryEmerald), + ], + ), + if (crv != null || maxLeverage > 0) ...[ + const Divider(color: Colors.white12, height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan), + if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent), + ], + ), + ], + ], + ), + ), + if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryEmerald.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald), + const SizedBox(width: 6), + const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildTradeStat('Tatsächl. Einstieg', '€${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white), + _buildTradeStat('Investition', posSize > 0 ? '€${_fmt(posSize)}' : 'N/A', Colors.white), + _buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald), + _buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70), + ], + ), + if (entryFee > 0 || exitFee > 0) ...[ + const SizedBox(height: 6), + Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + ], + ], + ), + ), + ], + if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[ + const SizedBox(height: 12), + Builder( + builder: (context) { + final pnlVal = trade.calculatedPnlAbs; + final pnlPctVal = trade.calculatedPnlPct; + final isWin = pnlVal >= 0; + final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(isWin ? Icons.trending_up : Icons.trending_down, size: 16, color: color), + const SizedBox(width: 6), + const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildTradeStat('Ausstiegskurs', trade.actualExitPrice > 0 ? '€${_fmt(trade.actualExitPrice)}' : 'N/A', Colors.white), + _buildTradeStat('Realisierter PnL (€)', '${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}', color), + _buildTradeStat('Rendite (%)', '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', isWin ? AppTheme.primaryEmerald : AppTheme.accentRed), + ], + ), + ], + ), + ); + }, + ), + ], + if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[ + const SizedBox(height: 12), + ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + dense: true, + title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)), + children: [ + if (reasoning.isNotEmpty) ...[ + _buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70), + const SizedBox(height: 8), + ], + if (techRationale.isNotEmpty) ...[ + _buildRationaleBlock('Technische Analyse', techRationale, Colors.white70), + const SizedBox(height: 8), + ], + if (fundRationale.isNotEmpty) ...[ + _buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70), + const SizedBox(height: 8), + ], + if (riskWarning.isNotEmpty) _buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed), + ], + ), + ], + ], + ), + ), + ); + } + + Widget _buildTradeStat(String title, String val, Color col) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), + const SizedBox(height: 2), + Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)), + ], + ); + } + + Widget _buildRationaleBlock(String title, String text, Color col) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)), + const SizedBox(height: 2), + Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)), + ], + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/close_trade_dialog.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/close_trade_dialog.dart new file mode 100644 index 0000000..9a33b31 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/close_trade_dialog.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../trades/models/trade_model.dart'; +import '../../../trades/models/close_trade_request_dto.dart'; + +class CloseTradeDialog { + static void show( + BuildContext context, { + required TradeModel trade, + required String defaultSymbol, + required void Function(CloseTradeRequestDto) onClose, + }) { + final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice; + final exitController = TextEditingController(text: entry.toStringAsFixed(2)); + + 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(Icons.flag_outlined, color: AppTheme.accentRed, size: 22), + const SizedBox(width: 8), + const Expanded( + child: Text('Trade Position Schließen', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), + ), + ], + ), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), + const SizedBox(height: 16), + TextField( + controller: exitController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Tatsächlicher Ausstiegskurs (€)', + hintText: 'Z.B. 105.50', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), + ), + ElevatedButton.icon( + onPressed: () { + final exitPrice = double.tryParse(exitController.text) ?? entry; + Navigator.pop(dialogContext); + onClose(CloseTradeRequestDto(userExitPrice: exitPrice)); + }, + icon: const Icon(Icons.check), + label: const Text('Position Schließen & Buchen'), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentRed, foregroundColor: Colors.white), + ), + ], + ); + }, + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/live_trade_settings_dialog.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/live_trade_settings_dialog.dart new file mode 100644 index 0000000..46e4bb6 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/live_trade_settings_dialog.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; + +class LiveTradeSettings { + final double defaultPositionSize; + final double defaultLeverage; + final double defaultRiskScore; + final double defaultOrderFee; + final bool autoAcceptSignals; + + const LiveTradeSettings({ + required this.defaultPositionSize, + required this.defaultLeverage, + required this.defaultRiskScore, + required this.defaultOrderFee, + required this.autoAcceptSignals, + }); +} + +class LiveTradeSettingsDialog { + static void show( + BuildContext context, { + required LiveTradeSettings currentSettings, + required ValueChanged onSave, + }) { + double tempPos = currentSettings.defaultPositionSize; + double tempLev = currentSettings.defaultLeverage; + double tempRisk = currentSettings.defaultRiskScore; + double tempFee = currentSettings.defaultOrderFee; + bool tempAuto = currentSettings.autoAcceptSignals; + + final posController = TextEditingController(text: tempPos.toStringAsFixed(0)); + final levController = TextEditingController(text: tempLev.toStringAsFixed(1)); + final feeController = TextEditingController(text: tempFee.toStringAsFixed(2)); + + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (builderContext, setModalState) { + return AlertDialog( + backgroundColor: AppTheme.cardSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppTheme.glassBorder), + ), + title: Row( + children: [ + Icon(Icons.settings, color: AppTheme.accentCyan, size: 22), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Live Trade Einstellungen', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ], + ), + content: SizedBox( + width: 440, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Standard Trade-Vorgaben für Ihr Depot:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: posController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Standard Investment (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + onChanged: (v) => tempPos = double.tryParse(v) ?? tempPos, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: levController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Standard Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + onChanged: (v) => tempLev = double.tryParse(v) ?? tempLev, + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: feeController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Standard Ordergebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + onChanged: (v) => tempFee = double.tryParse(v) ?? tempFee, + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Standard Risiko-Toleranz:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + Text('${tempRisk.toInt()}/100', style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 13)), + ], + ), + Slider( + value: tempRisk, + min: 0, + max: 100, + divisions: 100, + activeColor: AppTheme.primaryEmerald, + inactiveColor: AppTheme.glassSurface, + onChanged: (val) => setModalState(() => tempRisk = val), + ), + const SizedBox(height: 10), + SwitchListTile( + value: tempAuto, + activeThumbColor: AppTheme.primaryEmerald, + title: const Text('KI-Signale automatisch annehmen', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)), + subtitle: Text('Führt eingehende Signale direkt im Depot aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + onChanged: (val) => setModalState(() => tempAuto = val), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), + ), + ElevatedButton.icon( + onPressed: () { + onSave(LiveTradeSettings( + defaultPositionSize: tempPos, + defaultLeverage: tempLev, + defaultRiskScore: tempRisk, + defaultOrderFee: tempFee, + autoAcceptSignals: tempAuto, + )); + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Live Trade Einstellungen gespeichert.'), + backgroundColor: AppTheme.primaryEmerald, + behavior: SnackBarBehavior.floating, + ), + ); + }, + icon: const Icon(Icons.save), + label: const Text('Einstellungen Speichern'), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/manual_analysis_dialog.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/manual_analysis_dialog.dart new file mode 100644 index 0000000..b2e1fe1 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/manual_analysis_dialog.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../models/manual_analysis_request_dto.dart'; + +class ManualAnalysisDialog { + static void show( + BuildContext context, { + required String symbol, + required double initialRiskScore, + required void Function(ManualAnalysisRequestDto) onTrigger, + }) { + double riskScore = initialRiskScore; + final minTimeframeController = TextEditingController(text: '1'); + final maxTimeframeController = TextEditingController(text: '14'); + String timeframeUnit = 'Tage'; + String instrumentType = 'Knock-Out Zertifikat (Turbo)'; + final notesController = TextEditingController(); + + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (builderContext, setModalState) { + return AlertDialog( + backgroundColor: AppTheme.cardSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppTheme.glassBorder), + ), + title: Row( + children: [ + Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22), + const SizedBox(width: 8), + Expanded( + child: Text('KI-Analyse für $symbol', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), + ), + ], + ), + content: SizedBox( + width: 440, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Wählen Sie Ihre Zielparameter für die Trade-Evaluierung:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), + const SizedBox(height: 16), + const Text('Zeithorizont (Timeframe):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: TextField( + controller: minTimeframeController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: maxTimeframeController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + initialValue: timeframeUnit, + dropdownColor: AppTheme.cardSurface, + decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + items: const [ + DropdownMenuItem(value: 'Stunden', child: Text('Stunden')), + DropdownMenuItem(value: 'Tage', child: Text('Tage')), + DropdownMenuItem(value: 'Wochen', child: Text('Wochen')), + DropdownMenuItem(value: 'Monate', child: Text('Monate')), + ], + onChanged: (val) { + if (val != null) setModalState(() => timeframeUnit = val); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + Text( + '${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})', + style: TextStyle( + color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ], + ), + Slider( + value: riskScore, + min: 0, + max: 100, + divisions: 100, + activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), + inactiveColor: AppTheme.glassSurface, + onChanged: (val) => setModalState(() => riskScore = val), + ), + const SizedBox(height: 12), + const Text('Instrumententyp (Trade Republic):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: instrumentType, + dropdownColor: AppTheme.cardSurface, + decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)), + items: const [ + DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')), + DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')), + DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo)')), + DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')), + DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')), + ], + onChanged: (val) { + if (val != null) setModalState(() => instrumentType = val); + }, + ), + const SizedBox(height: 16), + const Text('Anmerkung für die KI:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 6), + TextField( + controller: notesController, + maxLines: 3, + decoration: const InputDecoration( + hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...', + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), + ), + ElevatedButton.icon( + onPressed: () { + final payload = ManualAnalysisRequestDto( + isin: symbol, + symbol: symbol, + riskScore: riskScore.toInt(), + minTimeframeValue: int.tryParse(minTimeframeController.text) ?? 1, + maxTimeframeValue: int.tryParse(maxTimeframeController.text) ?? 14, + timeframeUnit: timeframeUnit, + instrumentType: instrumentType, + userNotes: notesController.text, + headline: 'Manuelle KI-Analyse für $symbol', + ); + Navigator.pop(dialogContext); + onTrigger(payload); + }, + icon: const Icon(Icons.flash_on), + label: const Text('Analyse Jetzt Ausführen'), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black), + ), + ], + ); + }, + ); + }, + ); + } +}