feat(asset_detail): modular fundamentals sections, executive salaries and logo resolution
This commit is contained in:
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'title': title,
|
||||||
|
if (age != null) 'age': age,
|
||||||
|
if (compensation != null) 'compensation': compensation,
|
||||||
|
if (payment != null) 'payment': payment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [name, title, age, compensation, payment];
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<Object?> 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'period': period,
|
||||||
|
if (expectedRevenue != null) 'expectedRevenue': expectedRevenue,
|
||||||
|
if (expectedEps != null) 'expectedEps': expectedEps,
|
||||||
|
if (expectedGrowthRate != null) 'expectedGrowthRate': expectedGrowthRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
|
||||||
|
}
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
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 {
|
class FundamentalDataModel extends Equatable {
|
||||||
final String isin;
|
final String isin;
|
||||||
@@ -16,10 +25,10 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final double currentPrice;
|
final double currentPrice;
|
||||||
final double dayChangeAbsolute;
|
final double dayChangeAbsolute;
|
||||||
final double dayChangePercent;
|
final double dayChangePercent;
|
||||||
final double fiftyTwoWeekHigh;
|
final double? fiftyTwoWeekHigh;
|
||||||
final double fiftyTwoWeekLow;
|
final double? fiftyTwoWeekLow;
|
||||||
final double marketCapitalization;
|
final double? marketCapitalization;
|
||||||
final double enterpriseValue;
|
final double? enterpriseValue;
|
||||||
|
|
||||||
final double? peRatioTrailing;
|
final double? peRatioTrailing;
|
||||||
final double? peRatioForward;
|
final double? peRatioForward;
|
||||||
@@ -85,10 +94,10 @@ class FundamentalDataModel extends Equatable {
|
|||||||
required this.currentPrice,
|
required this.currentPrice,
|
||||||
required this.dayChangeAbsolute,
|
required this.dayChangeAbsolute,
|
||||||
required this.dayChangePercent,
|
required this.dayChangePercent,
|
||||||
required this.fiftyTwoWeekHigh,
|
this.fiftyTwoWeekHigh,
|
||||||
required this.fiftyTwoWeekLow,
|
this.fiftyTwoWeekLow,
|
||||||
required this.marketCapitalization,
|
this.marketCapitalization,
|
||||||
required this.enterpriseValue,
|
this.enterpriseValue,
|
||||||
this.peRatioTrailing,
|
this.peRatioTrailing,
|
||||||
this.peRatioForward,
|
this.peRatioForward,
|
||||||
this.pegRatio,
|
this.pegRatio,
|
||||||
@@ -135,12 +144,6 @@ class FundamentalDataModel extends Equatable {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
|
factory FundamentalDataModel.fromJson(Map<String, dynamic> 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) {
|
double? parseNullableDouble(dynamic val) {
|
||||||
if (val == null) return null;
|
if (val == null) return null;
|
||||||
if (val is num) return val.toDouble();
|
if (val is num) return val.toDouble();
|
||||||
@@ -171,8 +174,8 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
|
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
|
||||||
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
|
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
|
||||||
: primaryTickerVal;
|
: primaryTickerVal;
|
||||||
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? json['name']?.toString() ?? tickerVal;
|
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? tickerVal;
|
||||||
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString() ?? json['description']?.toString();
|
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString();
|
||||||
|
|
||||||
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
|
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
|
||||||
extractExchangeStr(assetMap?['primaryTicker']) ??
|
extractExchangeStr(assetMap?['primaryTicker']) ??
|
||||||
@@ -181,16 +184,12 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
|
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
|
||||||
List<TickerModel> availableTickersList = [];
|
List<TickerModel> availableTickersList = [];
|
||||||
if (rawTickers is List) {
|
if (rawTickers is List) {
|
||||||
availableTickersList = rawTickers.map((t) {
|
availableTickersList = rawTickers
|
||||||
if (t is Map<String, dynamic>) {
|
.whereType<Map<String, dynamic>>()
|
||||||
return TickerModel.fromJson(t);
|
.map((t) => TickerModel.fromJson(t))
|
||||||
} else {
|
.toList();
|
||||||
return TickerModel(ticker: t.toString());
|
|
||||||
}
|
|
||||||
}).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revenue & Margins Derivation
|
|
||||||
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
|
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
|
||||||
final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
|
final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
|
||||||
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargin'] ?? json['grossMargin']);
|
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']);
|
final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']);
|
||||||
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
|
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
|
||||||
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
|
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
|
||||||
evToRevVal = evVal / totalRev;
|
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 = <Map<String, dynamic>>[];
|
|
||||||
for (final ev in rawEvents) {
|
|
||||||
if (ev is Map<String, dynamic>) {
|
|
||||||
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(
|
return FundamentalDataModel(
|
||||||
isin: isinVal,
|
isin: isinVal,
|
||||||
primaryTicker: primaryTickerVal,
|
primaryTicker: primaryTickerVal,
|
||||||
ticker: tickerVal,
|
ticker: tickerVal,
|
||||||
companyName: companyNameVal,
|
companyName: companyNameVal,
|
||||||
exchange: exchangeVal,
|
exchange: exchangeVal,
|
||||||
tradingCurrency: json['tradingCurrency']?.toString(),
|
tradingCurrency: fundMap?['currency']?.toString() ?? json['tradingCurrency']?.toString(),
|
||||||
businessSummary: businessSummaryVal,
|
businessSummary: businessSummaryVal,
|
||||||
sector: json['sector']?.toString(),
|
sector: assetMap?['sector']?.toString() ?? json['sector']?.toString(),
|
||||||
industry: json['industry']?.toString(),
|
industry: assetMap?['industry']?.toString() ?? json['industry']?.toString(),
|
||||||
country: json['country']?.toString(),
|
country: assetMap?['country']?.toString() ?? json['country']?.toString(),
|
||||||
employees: json['employees'] != null ? int.tryParse(json['employees'].toString()) : null,
|
employees: (assetMap?['employees'] ?? json['employees']) is int
|
||||||
currentPrice: parseDouble(json['currentPrice']),
|
? (assetMap?['employees'] ?? json['employees']) as int
|
||||||
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
|
: int.tryParse((assetMap?['employees'] ?? json['employees'])?.toString() ?? ''),
|
||||||
dayChangePercent: parseDouble(json['dayChangePercent']),
|
currentPrice: parseNullableDouble(fundMap?['currentPrice'] ?? json['currentPrice']) ?? 0.0,
|
||||||
fiftyTwoWeekHigh: parseDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
|
dayChangeAbsolute: parseNullableDouble(fundMap?['dayChangeAbsolute'] ?? json['dayChangeAbsolute']) ?? 0.0,
|
||||||
fiftyTwoWeekLow: parseDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
|
dayChangePercent: parseNullableDouble(fundMap?['dayChangePercent'] ?? json['dayChangePercent']) ?? 0.0,
|
||||||
marketCapitalization: parseDouble(fundMap?['marketCap'] ?? json['marketCapitalization'] ?? json['marketCap']),
|
fiftyTwoWeekHigh: parseNullableDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
|
||||||
enterpriseValue: parseDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']),
|
fiftyTwoWeekLow: parseNullableDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
|
||||||
peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? json['peRatioTrailing'] ?? json['peRatio']),
|
marketCapitalization: parseNullableDouble(fundMap?['marketCap'] ?? fundMap?['marketCapitalization'] ?? json['marketCapitalization']),
|
||||||
peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? json['peRatioForward']),
|
enterpriseValue: evVal,
|
||||||
|
peRatioTrailing: parseNullableDouble(fundMap?['trailingPE'] ?? fundMap?['peRatioTrailing'] ?? json['peRatioTrailing']),
|
||||||
|
peRatioForward: parseNullableDouble(fundMap?['forwardPE'] ?? fundMap?['peRatioForward'] ?? json['peRatioForward']),
|
||||||
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
|
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
|
||||||
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? json['pbRatio']),
|
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? fundMap?['pbRatio'] ?? json['pbRatio']),
|
||||||
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? json['psRatio']),
|
psRatio: parseNullableDouble(fundMap?['priceToSalesTrailing12Months'] ?? fundMap?['psRatio'] ?? json['psRatio']),
|
||||||
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? json['evToEbitda']),
|
evToEbitda: parseNullableDouble(fundMap?['enterpriseToEbitda'] ?? fundMap?['evToEbitda'] ?? json['evToEbitda']),
|
||||||
evToRevenue: evToRevVal,
|
evToRevenue: evToRevVal,
|
||||||
totalRevenue: totalRev,
|
totalRevenue: totalRev,
|
||||||
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
|
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowth'] ?? fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
|
||||||
grossProfit: grossProf,
|
grossProfit: grossProf,
|
||||||
ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']),
|
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']),
|
totalCash: parseNullableDouble(fundMap?['totalCash'] ?? json['totalCash']),
|
||||||
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
|
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
|
||||||
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
|
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashflow'] ?? fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
|
||||||
freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
|
freeCashFlow: parseNullableDouble(fundMap?['freeCashflow'] ?? fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
|
||||||
grossMargin: grossMarginVal,
|
grossMargin: grossMarginVal,
|
||||||
operatingMargin: parseNullableDouble(fundMap?['operatingMargin'] ?? fundMap?['operatingIncome'] ?? json['operatingMargin']),
|
operatingMargin: parseNullableDouble(fundMap?['operatingMargins'] ?? fundMap?['operatingMargin'] ?? json['operatingMargin']),
|
||||||
netProfitMargin: parseNullableDouble(fundMap?['netProfitMargin'] ?? fundMap?['netIncome'] ?? json['netProfitMargin']),
|
netProfitMargin: parseNullableDouble(fundMap?['profitMargins'] ?? fundMap?['netProfitMargin'] ?? json['netProfitMargin']),
|
||||||
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
|
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
|
||||||
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
|
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
|
||||||
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
|
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
|
||||||
@@ -299,424 +254,51 @@ class FundamentalDataModel extends Equatable {
|
|||||||
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
|
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
|
||||||
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
|
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
|
||||||
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
|
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
|
||||||
dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? json['dividendYield']),
|
dividendYield: parseNullableDouble(fundMap?['dividendYield'] ?? json['dividendYield']),
|
||||||
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
|
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
|
||||||
exDividendDate: exDividendDateVal,
|
exDividendDate: fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString(),
|
||||||
nextEarningsDate: nextEarningsDateVal,
|
nextEarningsDate: fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString(),
|
||||||
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
|
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
|
||||||
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
|
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
|
||||||
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
|
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
|
||||||
shortPercentOfFloat: parseNullableDouble(fundMap?['shortPercentOfFloat'] ?? json['shortPercentOfFloat']),
|
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']),
|
priceTargetLow: parseNullableDouble(fundMap?['priceTargetLow'] ?? json['priceTargetLow']),
|
||||||
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
|
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
|
||||||
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
|
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
|
||||||
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
|
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
|
||||||
executives: (json['executives'] as List?)
|
executives: (json['executives'] as List?)
|
||||||
?.map((e) => CompanyExecutiveModel.fromJson(e is Map<String, dynamic> ? e : {}))
|
?.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => CompanyExecutiveModel.fromJson(e))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
financialStatements: (json['financialStatements'] as List?)
|
financialStatements: (json['financialStatements'] as List?)
|
||||||
?.map((e) => FinancialStatementModel.fromJson(e is Map<String, dynamic> ? e : {}))
|
?.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => FinancialStatementModel.fromJson(e))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
estimates: (json['estimates'] as List?)
|
estimates: (json['estimates'] as List?)
|
||||||
?.map((e) => ForwardEstimateModel.fromJson(e is Map<String, dynamic> ? e : {}))
|
?.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => ForwardEstimateModel.fromJson(e))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
availableTickers: availableTickersList,
|
availableTickers: availableTickersList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> 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
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
isin,
|
isin, primaryTicker, ticker, companyName, exchange, tradingCurrency,
|
||||||
primaryTicker,
|
businessSummary, sector, industry, country, employees, currentPrice,
|
||||||
ticker,
|
dayChangeAbsolute, dayChangePercent, fiftyTwoWeekHigh, fiftyTwoWeekLow,
|
||||||
companyName,
|
marketCapitalization, enterpriseValue, peRatioTrailing, peRatioForward,
|
||||||
exchange,
|
pegRatio, pbRatio, psRatio, evToEbitda, evToRevenue, grossMargin,
|
||||||
tradingCurrency,
|
operatingMargin, netProfitMargin, returnOnEquity, returnOnAssets,
|
||||||
businessSummary,
|
returnOnInvestedCapital, debtToEquity, currentRatio, quickRatio,
|
||||||
sector,
|
dividendYield, payoutRatio, exDividendDate, nextEarningsDate,
|
||||||
industry,
|
percentHeldByInstitutions, percentHeldByInsiders, shortRatio,
|
||||||
country,
|
shortPercentOfFloat, consensusRating, priceTargetLow, priceTargetHigh,
|
||||||
employees,
|
priceTargetMedian, priceTargetMean, executives, financialStatements,
|
||||||
currentPrice,
|
estimates, availableTickers,
|
||||||
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<String, dynamic> 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<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'name': name,
|
|
||||||
'title': title,
|
|
||||||
'age': age,
|
|
||||||
'compensation': compensation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> 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<String, dynamic> 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<String, dynamic> 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<Object?> 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<String, dynamic> 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<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'period': period,
|
|
||||||
'expectedRevenue': expectedRevenue,
|
|
||||||
'expectedEps': expectedEps,
|
|
||||||
'expectedGrowthRate': expectedGrowthRate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> 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<String, dynamic> 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<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'ticker': ticker,
|
|
||||||
'exchange': exchange,
|
|
||||||
'tradingCurrency': tradingCurrency,
|
|
||||||
'currentPrice': currentPrice,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<String, dynamic> json) {
|
||||||
|
return TickerModel(
|
||||||
|
ticker: json['ticker']?.toString() ?? '',
|
||||||
|
exchange: json['exchange']?.toString(),
|
||||||
|
tradingCurrency: json['tradingCurrency']?.toString(),
|
||||||
|
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'ticker': ticker,
|
||||||
|
if (exchange != null) 'exchange': exchange,
|
||||||
|
if (tradingCurrency != null) 'tradingCurrency': tradingCurrency,
|
||||||
|
if (currentPrice != null) 'currentPrice': currentPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
|
||||||
|
}
|
||||||
@@ -1,103 +1,124 @@
|
|||||||
import 'package:finlytic_app/core/network/api_client.dart';
|
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/fundamental_data_model.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_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_request_dto.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_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_model.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.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 {
|
class AssetRepository {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
final TradeRepository _tradeRepository;
|
||||||
|
|
||||||
AssetRepository({required this.apiClient});
|
// In-memory request deduplication & cache
|
||||||
|
final Map<String, Future<FundamentalDataModel?>> _pendingFundamentals = {};
|
||||||
|
final Map<String, FundamentalDataModel> _fundamentalsCache = {};
|
||||||
|
|
||||||
|
final Map<String, Future<TechnicalAnalysisModel?>> _pendingTechnicals = {};
|
||||||
|
final Map<String, TechnicalAnalysisModel> _technicalsCache = {};
|
||||||
|
|
||||||
|
AssetRepository({required this.apiClient, TradeRepository? tradeRepository})
|
||||||
|
: _tradeRepository = tradeRepository ?? TradeRepository(apiClient: apiClient);
|
||||||
|
|
||||||
|
String _buildCacheKey(String isin, String? ticker) => '${isin.toUpperCase()}_${(ticker ?? '').toUpperCase()}';
|
||||||
|
|
||||||
Future<FundamentalDataModel?> getAssetFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
|
Future<FundamentalDataModel?> 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<FundamentalDataModel?> _fetchFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
|
||||||
try {
|
try {
|
||||||
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
|
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
|
||||||
if (ticker != null && ticker.isNotEmpty) {
|
if (ticker != null && ticker.isNotEmpty) {
|
||||||
url += '&ticker=$ticker';
|
url += '&ticker=$ticker';
|
||||||
}
|
}
|
||||||
final res = await apiClient.get(url);
|
final res = await apiClient.get(url);
|
||||||
if (res.statusCode == 200 && res.data != null) {
|
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||||
return FundamentalDataModel.fromJson(res.data);
|
return FundamentalDataModel.fromJson(res.data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
print('Error fetching fundamentals for $isin: $e');
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
|
Future<TechnicalAnalysisModel?> 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<TechnicalAnalysisModel?> _fetchTechnicals(String isin, bool forceRefresh, {String? ticker}) async {
|
||||||
try {
|
try {
|
||||||
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
|
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
|
||||||
if (ticker != null && ticker.isNotEmpty) {
|
if (ticker != null && ticker.isNotEmpty) {
|
||||||
url += '&ticker=$ticker';
|
url += '&ticker=$ticker';
|
||||||
}
|
}
|
||||||
final res = await apiClient.get(url);
|
final res = await apiClient.get(url);
|
||||||
if (res.statusCode == 200 && res.data != null) {
|
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||||
return TechnicalAnalysisModel.fromJson(res.data);
|
return TechnicalAnalysisModel.fromJson(res.data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
print('Error fetching TA for $isin: $e');
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
||||||
try {
|
return _tradeRepository.fetchTrades(isin: isin, status: status);
|
||||||
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<dynamic> list = res.data;
|
|
||||||
return list.map((json) => TradeModel.fromJson(json)).toList();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error fetching trades for $isin: $e');
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||||
try {
|
final body = payload != null ? payload.toJson() : {'isin': isin};
|
||||||
final body = payload != null ? payload.toJson() : {'isin': isin};
|
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
||||||
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||||
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
return ManualAnalysisResponseDto.fromJson(res.data);
|
||||||
return ManualAnalysisResponseDto.fromJson(res.data);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
print('Error triggering manual analysis for $isin: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> rejectTrade(String tradeId) async {
|
Future<void> rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId);
|
||||||
try {
|
|
||||||
await apiClient.post('/api/v1/user/trades/$tradeId/reject');
|
|
||||||
} catch (e) {
|
|
||||||
print('Error rejecting trade $tradeId: $e');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async {
|
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
|
||||||
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<void> closeTrade(String tradeId, double exitPrice) async {
|
Future<void> closeTrade(String tradeId, double exitPrice) async =>
|
||||||
try {
|
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
|
||||||
await apiClient.post('/api/v1/user/trades/$tradeId/close', data: {'userExitPrice': exitPrice});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error closing trade $tradeId: $e');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
static void show(BuildContext context, String key) {
|
||||||
final info = data[key];
|
final info = data[key];
|
||||||
if (info == null) return;
|
if (info == null) return;
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ class PatternExplanations {
|
|||||||
return colors[patternType.hashCode.abs() % colors.length];
|
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) {
|
static void showPatternDetails(BuildContext context, String rawPatternType) {
|
||||||
final key = dictionary.keys.firstWhere(
|
final key = dictionary.keys.firstWhere(
|
||||||
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
|
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../bloc/header/asset_header_bloc.dart';
|
import '../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||||
import '../bloc/header/asset_header_event.dart';
|
|
||||||
import '../bloc/technical/asset_technical_bloc.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_bloc.dart';
|
||||||
import '../bloc/trades/asset_trades_event.dart';
|
import '../bloc/trades/asset_trades_event.dart';
|
||||||
import '../repositories/asset_repository.dart';
|
import '../repositories/asset_repository.dart';
|
||||||
@@ -32,14 +32,12 @@ class AssetDetailScreen extends StatelessWidget {
|
|||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetHeaderBloc(repository: repository)
|
create: (context) => AssetFundamentalsBloc(repository: repository)
|
||||||
..add(LoadAssetHeader(isin, ticker: symbol)),
|
..add(LoadAssetFundamentals(isin, ticker: symbol)),
|
||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetFundamentalsBloc(repository: repository),
|
create: (context) => AssetTechnicalBloc(repository: repository)
|
||||||
),
|
..add(LoadAssetTechnical(isin, ticker: symbol)),
|
||||||
BlocProvider(
|
|
||||||
create: (context) => AssetTechnicalBloc(repository: repository),
|
|
||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetTradesBloc(repository: repository)
|
create: (context) => AssetTradesBloc(repository: repository)
|
||||||
@@ -67,3 +65,4 @@ class AssetDetailScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+98
-121
@@ -4,9 +4,6 @@ import '../../../../core/theme/app_theme.dart';
|
|||||||
import '../../../favorites/cubit/favorites_cubit.dart';
|
import '../../../favorites/cubit/favorites_cubit.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.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_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/trades/asset_trades_bloc.dart';
|
import '../../bloc/trades/asset_trades_bloc.dart';
|
||||||
@@ -21,8 +18,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
|
|||||||
final String? name;
|
final String? name;
|
||||||
final String? selectedTicker;
|
final String? selectedTicker;
|
||||||
|
|
||||||
const AssetPageDesktopLayout(
|
const AssetPageDesktopLayout({
|
||||||
{super.key, required this.isin, this.selectedTicker, this.name});
|
super.key,
|
||||||
|
required this.isin,
|
||||||
|
this.selectedTicker,
|
||||||
|
this.name,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
|
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
|
||||||
@@ -31,12 +32,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
|
|||||||
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
String? _selectedExchange;
|
|
||||||
String? _selectedTicker;
|
String? _selectedTicker;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_selectedTicker = widget.selectedTicker;
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +49,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
|
|
||||||
void _handleExchangeChanged(String newExchange, String newTicker) {
|
void _handleExchangeChanged(String newExchange, String newTicker) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedExchange = newExchange;
|
|
||||||
_selectedTicker = newTicker;
|
_selectedTicker = newTicker;
|
||||||
});
|
});
|
||||||
context.read<AssetHeaderBloc>().add(
|
|
||||||
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
ticker: newTicker, forceRefresh: false));
|
ticker: newTicker, forceRefresh: false));
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
@@ -65,10 +63,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleForceRefresh() {
|
void _handleForceRefresh() {
|
||||||
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
forceRefresh: true,
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
exchange: _selectedExchange,
|
|
||||||
ticker: _selectedTicker));
|
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
ticker: _selectedTicker, forceRefresh: true));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||||
@@ -78,119 +74,100 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
|
return SingleChildScrollView(
|
||||||
listener: (context, state) {
|
physics: const BouncingScrollPhysics(),
|
||||||
if (state is AssetHeaderLoaded && state.data != null) {
|
child: Column(
|
||||||
if (_selectedTicker == null) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
setState(() {
|
children: [
|
||||||
_selectedTicker = widget.selectedTicker;
|
// 1. Hero Header
|
||||||
});
|
AssetHeroHeader(
|
||||||
}
|
isin: widget.isin,
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
name: widget.name ?? widget.isin,
|
||||||
widget.isin,
|
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||||
ticker: _selectedTicker,
|
onExchangeChanged: _handleExchangeChanged,
|
||||||
forceRefresh: false));
|
onForceRefresh: _handleForceRefresh,
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
),
|
||||||
widget.isin,
|
|
||||||
ticker: _selectedTicker,
|
// 2. Full-Width Interactive Chart Section
|
||||||
forceRefresh: false));
|
Container(
|
||||||
}
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
child: SingleChildScrollView(
|
color: theme.cardSurface,
|
||||||
physics: const BouncingScrollPhysics(),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Column(
|
border: Border.all(color: theme.glassBorder),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
child: TechnicalTab(
|
||||||
// 1. Hero Header
|
|
||||||
AssetHeroHeader(
|
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
name: widget.name ?? widget.isin,
|
symbol: _selectedTicker,
|
||||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
showChartOnly: true,
|
||||||
onExchangeChanged: _handleExchangeChanged,
|
chartHeight: 460,
|
||||||
onForceRefresh: _handleForceRefresh,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 2. Full-Width Interactive Chart Section
|
const SizedBox(height: 8),
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
// 3. Detailed Sections & Fundamentals under the Chart
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: theme.cardSurface,
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
borderRadius: BorderRadius.circular(16),
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: theme.glassBorder),
|
color: theme.cardSurface,
|
||||||
),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: TechnicalTab(
|
border: Border.all(color: theme.glassBorder),
|
||||||
isin: widget.isin,
|
|
||||||
symbol: _selectedTicker,
|
|
||||||
showChartOnly: true,
|
|
||||||
chartHeight: 460,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: Column(
|
||||||
const SizedBox(height: 8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
// 3. Detailed Sections & Fundamentals under the Chart
|
TabBar(
|
||||||
Container(
|
controller: _tabController,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
labelColor: theme.primaryColor,
|
||||||
decoration: BoxDecoration(
|
unselectedLabelColor: theme.textMuted,
|
||||||
color: theme.cardSurface,
|
indicatorColor: theme.primaryColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
dividerColor: theme.glassBorder,
|
||||||
border: Border.all(color: theme.glassBorder),
|
labelStyle: const TextStyle(
|
||||||
),
|
fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
child: Column(
|
tabs: const [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Tab(
|
||||||
children: [
|
icon: Icon(Icons.analytics_outlined, size: 18),
|
||||||
TabBar(
|
text: 'FUNDAMENTALS & ÜBERSICHT'),
|
||||||
controller: _tabController,
|
Tab(
|
||||||
labelColor: theme.primaryColor,
|
icon: Icon(Icons.architecture_outlined, size: 18),
|
||||||
unselectedLabelColor: theme.textMuted,
|
text: 'MUSTER & SIGNALE'),
|
||||||
indicatorColor: theme.primaryColor,
|
Tab(
|
||||||
dividerColor: theme.glassBorder,
|
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
|
||||||
labelStyle: const TextStyle(
|
text: 'TRADES'),
|
||||||
fontWeight: FontWeight.bold, fontSize: 13),
|
],
|
||||||
tabs: const [
|
),
|
||||||
Tab(
|
AnimatedBuilder(
|
||||||
icon: Icon(Icons.analytics_outlined, size: 18),
|
animation: _tabController,
|
||||||
text: 'FUNDAMENTALS & ÜBERSICHT'),
|
builder: (context, _) {
|
||||||
Tab(
|
switch (_tabController.index) {
|
||||||
icon: Icon(Icons.architecture_outlined, size: 18),
|
case 0:
|
||||||
text: 'MUSTER & SIGNALE'),
|
return FundamentalsTab(
|
||||||
Tab(
|
isin: widget.isin,
|
||||||
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
|
symbol: _selectedTicker,
|
||||||
text: 'TRADES'),
|
isEmbedded: true,
|
||||||
],
|
);
|
||||||
),
|
case 1:
|
||||||
AnimatedBuilder(
|
return TechnicalTab(
|
||||||
animation: _tabController,
|
isin: widget.isin,
|
||||||
builder: (context, _) {
|
symbol: _selectedTicker,
|
||||||
switch (_tabController.index) {
|
showDetailsOnly: true,
|
||||||
case 0:
|
);
|
||||||
return FundamentalsTab(
|
case 2:
|
||||||
isin: widget.isin,
|
return SizedBox(
|
||||||
symbol: _selectedTicker,
|
height: 600,
|
||||||
isEmbedded: true,
|
child: TradesTab(symbol: widget.isin),
|
||||||
);
|
);
|
||||||
case 1:
|
default:
|
||||||
return TechnicalTab(
|
return const SizedBox.shrink();
|
||||||
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),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-115
@@ -4,9 +4,6 @@ import '../../../../core/theme/app_theme.dart';
|
|||||||
import '../../../favorites/cubit/favorites_cubit.dart';
|
import '../../../favorites/cubit/favorites_cubit.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.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_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/trades/asset_trades_bloc.dart';
|
import '../../bloc/trades/asset_trades_bloc.dart';
|
||||||
@@ -21,8 +18,12 @@ class AssetPageMobileLayout extends StatefulWidget {
|
|||||||
final String? name;
|
final String? name;
|
||||||
final String? selectedTicker;
|
final String? selectedTicker;
|
||||||
|
|
||||||
const AssetPageMobileLayout(
|
const AssetPageMobileLayout({
|
||||||
{super.key, required this.isin, this.selectedTicker, this.name});
|
super.key,
|
||||||
|
required this.isin,
|
||||||
|
this.selectedTicker,
|
||||||
|
this.name,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
|
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
|
||||||
@@ -36,7 +37,7 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
//_selectedTicker = widget.selectedTicker;
|
_selectedTicker = widget.selectedTicker;
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +49,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
|
|
||||||
void _handleExchangeChanged(String newExchange, String newTicker) {
|
void _handleExchangeChanged(String newExchange, String newTicker) {
|
||||||
setState(() {
|
setState(() {
|
||||||
//_selectedExchange = newExchange;
|
|
||||||
_selectedTicker = newTicker;
|
_selectedTicker = newTicker;
|
||||||
});
|
});
|
||||||
context.read<AssetHeaderBloc>().add(
|
|
||||||
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
ticker: newTicker, forceRefresh: false));
|
ticker: newTicker, forceRefresh: false));
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
@@ -65,10 +63,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleForceRefresh() {
|
void _handleForceRefresh() {
|
||||||
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
forceRefresh: true, ticker: _selectedTicker));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
// 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<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
ticker: _selectedTicker, forceRefresh: true));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||||
@@ -78,113 +74,102 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
|
return SingleChildScrollView(
|
||||||
listener: (context, state) {
|
physics: const BouncingScrollPhysics(),
|
||||||
if (state is AssetHeaderLoaded && state.data != null) {
|
child: Column(
|
||||||
if (_selectedTicker == null) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
setState(() {
|
children: [
|
||||||
_selectedTicker = widget.selectedTicker;
|
// 1. Hero Header
|
||||||
});
|
AssetHeroHeader(
|
||||||
}
|
isin: widget.isin,
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
name: widget.name ?? widget.isin,
|
||||||
widget.isin,
|
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||||
ticker: _selectedTicker,
|
onExchangeChanged: _handleExchangeChanged,
|
||||||
forceRefresh: false));
|
onForceRefresh: _handleForceRefresh,
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
),
|
||||||
widget.isin,
|
|
||||||
ticker: _selectedTicker,
|
// 2. Interactive Chart
|
||||||
forceRefresh: false));
|
Container(
|
||||||
}
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
child: SingleChildScrollView(
|
color: theme.cardSurface,
|
||||||
physics: const BouncingScrollPhysics(),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Column(
|
border: Border.all(color: theme.glassBorder),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
child: TechnicalTab(
|
||||||
// 1. Hero Header
|
|
||||||
AssetHeroHeader(
|
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
name: widget.name ?? widget.isin,
|
symbol: _selectedTicker,
|
||||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
showChartOnly: true,
|
||||||
onExchangeChanged: _handleExchangeChanged,
|
chartHeight: 320,
|
||||||
onForceRefresh: _handleForceRefresh,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 2. Full-Width Interactive Chart Section
|
const SizedBox(height: 8),
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
// 3. Tabbed Detailed Analysis
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: theme.cardSurface,
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
borderRadius: BorderRadius.circular(16),
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: theme.glassBorder),
|
color: theme.cardSurface,
|
||||||
),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: TechnicalTab(
|
border: Border.all(color: theme.glassBorder),
|
||||||
isin: widget.isin,
|
|
||||||
symbol: _selectedTicker,
|
|
||||||
showChartOnly: true,
|
|
||||||
chartHeight: 330,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: Column(
|
||||||
const SizedBox(height: 6),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
// 3. Tab Bar & Detailed Sections (Fundamentals, Signals, Trades)
|
TabBar(
|
||||||
Container(
|
controller: _tabController,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
labelColor: theme.primaryColor,
|
||||||
decoration: BoxDecoration(
|
unselectedLabelColor: theme.textMuted,
|
||||||
color: theme.cardSurface,
|
indicatorColor: theme.primaryColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
dividerColor: theme.glassBorder,
|
||||||
border: Border.all(color: theme.glassBorder),
|
isScrollable: true,
|
||||||
),
|
tabAlignment: TabAlignment.start,
|
||||||
child: Column(
|
labelStyle: const TextStyle(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
children: [
|
tabs: const [
|
||||||
TabBar(
|
Tab(
|
||||||
controller: _tabController,
|
icon: Icon(Icons.analytics_outlined, size: 16),
|
||||||
labelColor: theme.primaryColor,
|
text: 'FUNDAMENTALS'),
|
||||||
unselectedLabelColor: theme.textMuted,
|
Tab(
|
||||||
indicatorColor: theme.primaryColor,
|
icon: Icon(Icons.architecture_outlined, size: 16),
|
||||||
dividerColor: theme.glassBorder,
|
text: 'MUSTER & SIGNALE'),
|
||||||
labelStyle: const TextStyle(
|
Tab(
|
||||||
fontWeight: FontWeight.bold, fontSize: 12),
|
icon: Icon(Icons.candlestick_chart_outlined, size: 16),
|
||||||
tabs: const [
|
text: 'TRADES'),
|
||||||
Tab(text: 'FUNDAMENTALS'),
|
],
|
||||||
Tab(text: 'MUSTER & SIGNALE'),
|
),
|
||||||
Tab(text: 'TRADES'),
|
AnimatedBuilder(
|
||||||
],
|
animation: _tabController,
|
||||||
),
|
builder: (context, _) {
|
||||||
AnimatedBuilder(
|
switch (_tabController.index) {
|
||||||
animation: _tabController,
|
case 0:
|
||||||
builder: (context, _) {
|
return FundamentalsTab(
|
||||||
switch (_tabController.index) {
|
isin: widget.isin,
|
||||||
case 0:
|
symbol: _selectedTicker,
|
||||||
return FundamentalsTab(
|
isEmbedded: true,
|
||||||
isin: widget.isin,
|
);
|
||||||
symbol: _selectedTicker,
|
case 1:
|
||||||
isEmbedded: true,
|
return TechnicalTab(
|
||||||
);
|
isin: widget.isin,
|
||||||
case 1:
|
symbol: _selectedTicker,
|
||||||
return TechnicalTab(
|
showDetailsOnly: true,
|
||||||
isin: widget.isin,
|
);
|
||||||
symbol: _selectedTicker,
|
case 2:
|
||||||
showDetailsOnly: true,
|
return SizedBox(
|
||||||
);
|
height: 500,
|
||||||
case 2:
|
child: TradesTab(symbol: widget.isin),
|
||||||
return SizedBox(
|
);
|
||||||
height: 500,
|
default:
|
||||||
child: TradesTab(symbol: widget.isin),
|
return const SizedBox.shrink();
|
||||||
);
|
}
|
||||||
default:
|
},
|
||||||
return const SizedBox.shrink();
|
),
|
||||||
}
|
],
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
],
|
const SizedBox(height: 24),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.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_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
||||||
import '../../models/fundamental_data_model.dart';
|
import '../../widgets/fundamentals/analyst_price_target_card.dart';
|
||||||
import '../../utils/metric_explanations.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 isin;
|
||||||
final String? symbol;
|
final String? symbol;
|
||||||
final bool isEmbedded;
|
final bool isEmbedded;
|
||||||
@@ -22,22 +22,26 @@ class FundamentalsTab extends StatefulWidget {
|
|||||||
required this.isin,
|
required this.isin,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
String _getCurrencySymbol(String? ticker) {
|
||||||
State<FundamentalsTab> createState() => _FundamentalsTabState();
|
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')) {
|
||||||
class _FundamentalsTabState extends State<FundamentalsTab> {
|
return '€';
|
||||||
String _sym = '\$';
|
}
|
||||||
String _curCode = 'USD';
|
if (t.endsWith('.L')) return '£';
|
||||||
|
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CA\$';
|
||||||
@override
|
return '\$';
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
String _getCurrencyCode(String? ticker) {
|
||||||
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
|
if (ticker == null || ticker.isEmpty) return 'EUR';
|
||||||
super.didUpdateWidget(oldWidget);
|
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
|
@override
|
||||||
@@ -60,7 +64,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
|
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Erneut versuchen'),
|
label: const Text('Erneut versuchen'),
|
||||||
),
|
),
|
||||||
@@ -70,89 +74,68 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AssetFundamentalsLoaded) {
|
if (state is AssetFundamentalsLoaded && state.data != null) {
|
||||||
final data = state.data;
|
final data = state.data!;
|
||||||
if (data != null) {
|
final sym = _getCurrencySymbol(data.ticker);
|
||||||
_sym = _getCurrencySymbol(data.ticker);
|
final curCode = _getCurrencyCode(data.ticker);
|
||||||
_curCode = _getCurrencyCode(data.ticker);
|
|
||||||
}
|
|
||||||
if (data == null) {
|
|
||||||
return _buildEmptyState();
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 1. Analyst Forecasts & Price Targets Header Card
|
// 1. Analyst Forecasts & Price Targets Header Card
|
||||||
_buildPriceTargetCard(data),
|
AnalystPriceTargetCard(data: data, currencySymbol: sym),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
||||||
_buildCategoryPanels(data),
|
FundamentalCategoryPanels(data: data, currencySymbol: sym, currencyCode: curCode),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 3. Company Description & Detailed Executive Board
|
// 3. Company Description & Detailed Executive Board
|
||||||
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
|
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildProfileSection(data),
|
CompanyProfileSection(data: data, currencySymbol: sym),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildEmptyState();
|
return _buildEmptyState(context);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPriceTargetCard(FundamentalDataModel data) {
|
Widget _buildSectionHeader(String title, IconData icon) {
|
||||||
final rating = data.consensusRating ?? 'N/A';
|
return Row(
|
||||||
final targetMean = data.priceTargetMean;
|
children: [
|
||||||
final targetLow = data.priceTargetLow;
|
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
|
||||||
final targetHigh = data.priceTargetHigh;
|
const SizedBox(width: 8),
|
||||||
|
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
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) {
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
return Column(
|
return Center(
|
||||||
children: [
|
child: GlassContainer(
|
||||||
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
padding: const EdgeInsets.all(24),
|
||||||
const SizedBox(height: 4),
|
child: Column(
|
||||||
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
|
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<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Aktualisieren'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,16 +171,13 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Price Target Card Shimmer
|
|
||||||
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 3 Category Panels Shimmer
|
|
||||||
if (isDesktop)
|
if (isDesktop)
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -234,9 +214,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
panelShimmer(),
|
panelShimmer(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// Profile Section Shimmer
|
|
||||||
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
|
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
|
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
|
||||||
@@ -244,454 +222,4 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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<AssetFundamentalsBloc>().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});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import '../../../../core/theme/app_theme.dart';
|
|||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.dart';
|
import '../../../../core/widgets/shimmer_loading.dart';
|
||||||
import '../../../../core/widgets/status_badge.dart';
|
import '../../../../core/widgets/status_badge.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
import '../../../trades/models/trade_model.dart';
|
||||||
|
import '../../../trades/widgets/trade_execution_dialog.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 '../../bloc/trades/asset_trades_bloc.dart';
|
import '../../bloc/trades/asset_trades_bloc.dart';
|
||||||
import '../../bloc/trades/asset_trades_event.dart';
|
import '../../bloc/trades/asset_trades_event.dart';
|
||||||
import '../../bloc/trades/asset_trades_state.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 {
|
class TradesTab extends StatefulWidget {
|
||||||
final String symbol;
|
final String symbol;
|
||||||
@@ -22,13 +24,13 @@ class TradesTab extends StatefulWidget {
|
|||||||
|
|
||||||
class _TradesTabState extends State<TradesTab> {
|
class _TradesTabState extends State<TradesTab> {
|
||||||
bool _justTriggeredAnalysis = false;
|
bool _justTriggeredAnalysis = false;
|
||||||
|
LiveTradeSettings _settings = const LiveTradeSettings(
|
||||||
// Settings State
|
defaultPositionSize: 2500.0,
|
||||||
double _defaultPositionSize = 2500.0;
|
defaultLeverage: 5.0,
|
||||||
double _defaultLeverage = 5.0;
|
defaultRiskScore: 50.0,
|
||||||
double _defaultRiskScore = 50.0;
|
defaultOrderFee: 1.0,
|
||||||
double _defaultOrderFee = 1.0;
|
autoAcceptSignals: false,
|
||||||
bool _autoAcceptSignals = false;
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -36,413 +38,9 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
|
context.read<AssetTradesBloc>().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<AssetTradesBloc>();
|
|
||||||
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<AssetTradesBloc>();
|
|
||||||
|
|
||||||
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<String>(
|
|
||||||
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<String>(
|
|
||||||
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}) {
|
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
|
||||||
final tradesBloc = context.read<AssetTradesBloc>();
|
final tradesBloc = context.read<AssetTradesBloc>();
|
||||||
|
|
||||||
TradeExecutionDialog.show(
|
TradeExecutionDialog.show(
|
||||||
context,
|
context,
|
||||||
trade: trade,
|
trade: trade,
|
||||||
@@ -453,9 +51,7 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
final tId = trade.id;
|
final tId = trade.id;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(isActive
|
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
|
||||||
? 'Einstellungen für Trade $tId gespeichert!'
|
|
||||||
: 'Trade $tId angenommen & Position eröffnet!'),
|
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -497,7 +93,6 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Action Button & Settings Card
|
|
||||||
GlassContainer(
|
GlassContainer(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -525,7 +120,24 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () => _showAnalysisParametersDialog(context),
|
onPressed: () {
|
||||||
|
ManualAnalysisDialog.show(
|
||||||
|
context,
|
||||||
|
symbol: widget.symbol,
|
||||||
|
initialRiskScore: _settings.defaultRiskScore,
|
||||||
|
onTrigger: (payload) {
|
||||||
|
setState(() => _justTriggeredAnalysis = true);
|
||||||
|
context.read<AssetTradesBloc>().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),
|
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||||
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -538,7 +150,13 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: () => _showLiveTradeSettingsDialog(context),
|
onPressed: () {
|
||||||
|
LiveTradeSettingsDialog.show(
|
||||||
|
context,
|
||||||
|
currentSettings: _settings,
|
||||||
|
onSave: (newSettings) => setState(() => _settings = newSettings),
|
||||||
|
);
|
||||||
|
},
|
||||||
icon: const Icon(Icons.settings, color: Colors.white),
|
icon: const Icon(Icons.settings, color: Colors.white),
|
||||||
tooltip: 'Live Trade Einstellungen',
|
tooltip: 'Live Trade Einstellungen',
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
@@ -552,7 +170,6 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
if (state is AssetTradesLoading)
|
if (state is AssetTradesLoading)
|
||||||
_buildTradesShimmer(context)
|
_buildTradesShimmer(context)
|
||||||
else if (state is AssetTradesError)
|
else if (state is AssetTradesError)
|
||||||
@@ -618,313 +235,38 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
itemCount: trades.length,
|
itemCount: trades.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final trade = trades[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<AssetTradesBloc>().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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+73
@@ -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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+263
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/asset_logo_widget.dart';
|
import '../../../../core/widgets/asset_logo_widget.dart';
|
||||||
import '../../../../shared/widgets/favorite_star_button.dart';
|
import '../../../../shared/widgets/favorite_star_button.dart';
|
||||||
import '../../bloc/header/asset_header_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/header/asset_header_state.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
||||||
import '../../bloc/technical/asset_technical_bloc.dart';
|
import '../../bloc/technical/asset_technical_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_state.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';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class AssetHeroHeader extends StatelessWidget {
|
class AssetHeroHeader extends StatelessWidget {
|
||||||
@@ -20,35 +20,31 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
const AssetHeroHeader({
|
const AssetHeroHeader({
|
||||||
super.key,
|
super.key,
|
||||||
this.onExchangeChanged,
|
this.onExchangeChanged,
|
||||||
this.onForceRefresh, required this.isin, required this.name, this.symbol,
|
this.onForceRefresh,
|
||||||
|
required this.isin,
|
||||||
|
required this.name,
|
||||||
|
this.symbol,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return BlocBuilder<AssetHeaderBloc, AssetHeaderState>(
|
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
|
||||||
builder: (context, state) {
|
builder: (context, fundState) {
|
||||||
double? price;
|
String displayName = name;
|
||||||
String currency = 'EUR';
|
final String? logoUrl = isin.isNotEmpty ? '/api/v1/logo/$isin' : null;
|
||||||
List<AssetTickerOption> tickerOptions = [
|
List<TickerModel> tickerOptions = [
|
||||||
AssetTickerOption(ticker: 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: 0.0)
|
TickerModel(ticker: symbol ?? 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: null)
|
||||||
];
|
];
|
||||||
|
|
||||||
AssetModel? asset;
|
if (fundState is AssetFundamentalsLoaded && fundState.data != null) {
|
||||||
if (state is AssetHeaderLoaded) {
|
final data = fundState.data!;
|
||||||
asset = state.data;
|
if (data.companyName.isNotEmpty) {
|
||||||
} else if (state is AssetHeaderLoading) {
|
displayName = data.companyName;
|
||||||
asset = state.previousData;
|
}
|
||||||
}
|
if (data.availableTickers.isNotEmpty) {
|
||||||
|
tickerOptions = data.availableTickers;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,14 +84,14 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
AssetLogoWidget(symbolOrName: isin, imageUrl: asset?.image, size: 48),
|
AssetLogoWidget(symbolOrName: isin, imageUrl: logoUrl, size: 48),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SelectableText(
|
SelectableText(
|
||||||
name,
|
displayName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
@@ -138,7 +134,7 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
onPressed: onForceRefresh,
|
onPressed: onForceRefresh,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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: [
|
children: [
|
||||||
BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||||
builder: (context, taState) {
|
builder: (context, taState) {
|
||||||
double? livePrice = price;
|
double? livePrice = selectedOption.currentPrice;
|
||||||
String liveCurrency = selectedOption.tradingCurrency.isNotEmpty
|
String liveCurrency = selectedOption.tradingCurrency ?? 'EUR';
|
||||||
? selectedOption.tradingCurrency
|
|
||||||
: currency;
|
|
||||||
|
|
||||||
if (taState is AssetTechnicalLoaded && taState.data != null) {
|
if (taState is AssetTechnicalLoaded && taState.data != null) {
|
||||||
if (taState.data!.candles.isNotEmpty) {
|
if (taState.data!.candles.isNotEmpty) {
|
||||||
@@ -219,12 +213,12 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
(t) => t.ticker == newTicker,
|
(t) => t.ticker == newTicker,
|
||||||
orElse: () => tickerOptions.first,
|
orElse: () => tickerOptions.first,
|
||||||
);
|
);
|
||||||
onExchangeChanged!(opt.exchange, opt.ticker);
|
onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
return tickerOptions.map((opt) {
|
return tickerOptions.map((opt) {
|
||||||
final ex = opt.exchange;
|
final ex = opt.exchange ?? 'Unknown';
|
||||||
final tick = opt.ticker;
|
final tick = opt.ticker;
|
||||||
final label = '$tick ($ex)';
|
final label = '$tick ($ex)';
|
||||||
final isSelected = tick == symbol || ex == symbol;
|
final isSelected = tick == symbol || ex == symbol;
|
||||||
@@ -263,7 +257,7 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
Icon(Icons.business, size: 14, color: theme.accentColor),
|
Icon(Icons.business, size: 14, color: theme.accentColor),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'${selectedOption.ticker} (${selectedOption.exchange})',
|
'${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -285,3 +279,4 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LiveTradeSettings> 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>(
|
||||||
|
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<String>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user