feat(app): responsive asset detail layout, full width chart, reactive hero header, shimmer loaders and enriched fundamentals
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../models/asset_model.dart';
|
||||||
|
import '../../models/fundamental_data_model.dart';
|
||||||
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
import '../../repositories/asset_repository.dart';
|
||||||
import 'asset_header_event.dart';
|
import 'asset_header_event.dart';
|
||||||
import 'asset_header_state.dart';
|
import 'asset_header_state.dart';
|
||||||
import '../../repositories/asset_repository.dart';
|
|
||||||
import '../../models/asset_model.dart';
|
|
||||||
|
|
||||||
class AssetHeaderBloc extends Bloc<AssetHeaderEvent, AssetHeaderState> {
|
class AssetHeaderBloc extends Bloc<AssetHeaderEvent, AssetHeaderState> {
|
||||||
final AssetRepository repository;
|
final AssetRepository repository;
|
||||||
@@ -11,21 +13,41 @@ class AssetHeaderBloc extends Bloc<AssetHeaderEvent, AssetHeaderState> {
|
|||||||
final prevData = state is AssetHeaderLoaded ? (state as AssetHeaderLoaded).data : (state is AssetHeaderLoading ? (state as AssetHeaderLoading).previousData : null);
|
final prevData = state is AssetHeaderLoaded ? (state as AssetHeaderLoaded).data : (state is AssetHeaderLoading ? (state as AssetHeaderLoading).previousData : null);
|
||||||
emit(AssetHeaderLoading(previousData: prevData));
|
emit(AssetHeaderLoading(previousData: prevData));
|
||||||
try {
|
try {
|
||||||
final fundamentals = await repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker);
|
final results = await Future.wait([
|
||||||
|
repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker),
|
||||||
|
repository.getAssetTechnical(event.isin, event.forceRefresh, ticker: event.ticker),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final fundamentals = results[0] as FundamentalDataModel?;
|
||||||
|
final technical = results[1] as TechnicalAnalysisModel?;
|
||||||
|
|
||||||
if (fundamentals != null) {
|
if (fundamentals != null) {
|
||||||
|
double initialPrice = fundamentals.currentPrice;
|
||||||
|
String initialCurrency = fundamentals.tradingCurrency ?? 'EUR';
|
||||||
|
|
||||||
|
if (technical != null && technical.candles.isNotEmpty) {
|
||||||
|
final lastClose = technical.candles.last.close;
|
||||||
|
if (lastClose > 0) {
|
||||||
|
initialPrice = lastClose;
|
||||||
|
}
|
||||||
|
if (technical.currency.isNotEmpty) {
|
||||||
|
initialCurrency = technical.currency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final assetModel = AssetModel(
|
final assetModel = AssetModel(
|
||||||
isin: fundamentals.isin,
|
isin: fundamentals.isin,
|
||||||
symbol: fundamentals.primaryTicker.isNotEmpty ? fundamentals.primaryTicker : fundamentals.isin,
|
symbol: fundamentals.primaryTicker.isNotEmpty ? fundamentals.primaryTicker : fundamentals.isin,
|
||||||
name: fundamentals.companyName,
|
name: fundamentals.companyName,
|
||||||
currentPrice: fundamentals.currentPrice,
|
currentPrice: initialPrice,
|
||||||
currency: fundamentals.tradingCurrency ?? 'EUR',
|
currency: initialCurrency,
|
||||||
exchange: fundamentals.exchange ?? 'XETRA',
|
exchange: fundamentals.exchange ?? 'XETRA',
|
||||||
exchanges: [], // Can be populated if needed
|
exchanges: [],
|
||||||
tickers: fundamentals.availableTickers.map((t) => AssetTickerOption(
|
tickers: fundamentals.availableTickers.map((t) => AssetTickerOption(
|
||||||
ticker: t.ticker,
|
ticker: t.ticker,
|
||||||
exchange: t.exchange ?? 'Unknown',
|
exchange: t.exchange ?? 'Unknown',
|
||||||
tradingCurrency: t.tradingCurrency ?? fundamentals.tradingCurrency ?? 'EUR',
|
tradingCurrency: t.tradingCurrency ?? initialCurrency,
|
||||||
currentPrice: t.currentPrice,
|
currentPrice: t.currentPrice ?? initialPrice,
|
||||||
)).toList(),
|
)).toList(),
|
||||||
image: '/api/v1/logo/${fundamentals.isin}',
|
image: '/api/v1/logo/${fundamentals.isin}',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../trades/models/trade_model.dart';
|
||||||
import 'asset_trades_event.dart';
|
import 'asset_trades_event.dart';
|
||||||
import 'asset_trades_state.dart';
|
import 'asset_trades_state.dart';
|
||||||
import '../../repositories/asset_repository.dart';
|
import '../../repositories/asset_repository.dart';
|
||||||
@@ -17,9 +18,20 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
on<TriggerManualAnalysis>((event, emit) async {
|
on<TriggerManualAnalysis>((event, emit) async {
|
||||||
|
emit(AssetTradesLoading());
|
||||||
try {
|
try {
|
||||||
await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
final analysisRes = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
||||||
add(LoadAssetTrades(event.isin));
|
final existingTrades = await repository.getAssetTrades(event.isin, null);
|
||||||
|
|
||||||
|
final list = List<TradeModel>.from(existingTrades);
|
||||||
|
final newProposal = analysisRes?.proposal;
|
||||||
|
if (newProposal != null) {
|
||||||
|
final isDuplicate = list.any((t) => t.id == newProposal.id || (t.analysisId.isNotEmpty && t.analysisId == newProposal.analysisId));
|
||||||
|
if (!isDuplicate) {
|
||||||
|
list.insert(0, newProposal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(AssetTradesLoaded(list));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
|
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final double? evToEbitda;
|
final double? evToEbitda;
|
||||||
final double? evToRevenue;
|
final double? evToRevenue;
|
||||||
|
|
||||||
|
final double? totalRevenue;
|
||||||
|
final double? revenueGrowthYoY;
|
||||||
|
final double? grossProfit;
|
||||||
|
final double? ebitda;
|
||||||
|
final double? dilutedEps;
|
||||||
|
final double? totalCash;
|
||||||
|
final double? totalDebt;
|
||||||
|
final double? operatingCashFlow;
|
||||||
|
final double? freeCashFlow;
|
||||||
|
|
||||||
final double? grossMargin;
|
final double? grossMargin;
|
||||||
final double? operatingMargin;
|
final double? operatingMargin;
|
||||||
final double? netProfitMargin;
|
final double? netProfitMargin;
|
||||||
@@ -86,6 +96,15 @@ class FundamentalDataModel extends Equatable {
|
|||||||
this.psRatio,
|
this.psRatio,
|
||||||
this.evToEbitda,
|
this.evToEbitda,
|
||||||
this.evToRevenue,
|
this.evToRevenue,
|
||||||
|
this.totalRevenue,
|
||||||
|
this.revenueGrowthYoY,
|
||||||
|
this.grossProfit,
|
||||||
|
this.ebitda,
|
||||||
|
this.dilutedEps,
|
||||||
|
this.totalCash,
|
||||||
|
this.totalDebt,
|
||||||
|
this.operatingCashFlow,
|
||||||
|
this.freeCashFlow,
|
||||||
this.grossMargin,
|
this.grossMargin,
|
||||||
this.operatingMargin,
|
this.operatingMargin,
|
||||||
this.netProfitMargin,
|
this.netProfitMargin,
|
||||||
@@ -128,14 +147,121 @@ class FundamentalDataModel extends Equatable {
|
|||||||
return double.tryParse(val.toString());
|
return double.tryParse(val.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final assetMap = json['asset'] is Map<String, dynamic> ? json['asset'] as Map<String, dynamic> : null;
|
||||||
|
final fundMap = json['fundamentals'] is Map<String, dynamic> ? json['fundamentals'] as Map<String, dynamic> : null;
|
||||||
|
|
||||||
|
String extractTickerStr(dynamic val) {
|
||||||
|
if (val == null) return '';
|
||||||
|
if (val is Map<String, dynamic>) {
|
||||||
|
return val['ticker']?.toString() ?? '';
|
||||||
|
}
|
||||||
|
return val.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? extractExchangeStr(dynamic val) {
|
||||||
|
if (val == null) return null;
|
||||||
|
if (val is Map<String, dynamic>) {
|
||||||
|
return val['exchange']?.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isinVal = assetMap?['isin']?.toString() ?? json['isin']?.toString() ?? '';
|
||||||
|
final primaryTickerVal = extractTickerStr(assetMap?['primaryTicker'] ?? json['primaryTicker']);
|
||||||
|
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
|
||||||
|
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
|
||||||
|
: primaryTickerVal;
|
||||||
|
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? json['name']?.toString() ?? tickerVal;
|
||||||
|
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString() ?? json['description']?.toString();
|
||||||
|
|
||||||
|
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
|
||||||
|
extractExchangeStr(assetMap?['primaryTicker']) ??
|
||||||
|
json['exchange']?.toString();
|
||||||
|
|
||||||
|
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
|
||||||
|
List<TickerModel> availableTickersList = [];
|
||||||
|
if (rawTickers is List) {
|
||||||
|
availableTickersList = rawTickers.map((t) {
|
||||||
|
if (t is Map<String, dynamic>) {
|
||||||
|
return TickerModel.fromJson(t);
|
||||||
|
} else {
|
||||||
|
return TickerModel(ticker: t.toString());
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revenue & Margins Derivation
|
||||||
|
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
|
||||||
|
final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
|
||||||
|
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargin'] ?? json['grossMargin']);
|
||||||
|
if (grossMarginVal == null && grossProf != null) {
|
||||||
|
if (grossProf <= 1.0 && grossProf >= 0.0) {
|
||||||
|
grossMarginVal = grossProf;
|
||||||
|
} else if (totalRev != null && totalRev > 0) {
|
||||||
|
grossMarginVal = grossProf / totalRev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enterprise Value to Revenue
|
||||||
|
final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']);
|
||||||
|
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
|
||||||
|
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
|
||||||
|
evToRevVal = evVal / totalRev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Dates (Ex-Dividend & Next Earnings)
|
||||||
|
String? exDividendDateVal = json['exDividendDate']?.toString() ?? fundMap?['exDividendDate']?.toString();
|
||||||
|
String? nextEarningsDateVal = json['nextEarningsDate']?.toString() ?? fundMap?['nextEarningsDate']?.toString();
|
||||||
|
|
||||||
|
final rawEvents = json['events'];
|
||||||
|
if (rawEvents is List && rawEvents.isNotEmpty) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final parsedEvents = <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: json['isin']?.toString() ?? '',
|
isin: isinVal,
|
||||||
primaryTicker: json['primaryTicker']?.toString() ?? '',
|
primaryTicker: primaryTickerVal,
|
||||||
ticker: json['ticker']?.toString() ?? '',
|
ticker: tickerVal,
|
||||||
companyName: json['companyName']?.toString() ?? '',
|
companyName: companyNameVal,
|
||||||
exchange: json['exchange']?.toString(),
|
exchange: exchangeVal,
|
||||||
tradingCurrency: json['tradingCurrency']?.toString(),
|
tradingCurrency: json['tradingCurrency']?.toString(),
|
||||||
businessSummary: json['businessSummary']?.toString(),
|
businessSummary: businessSummaryVal,
|
||||||
sector: json['sector']?.toString(),
|
sector: json['sector']?.toString(),
|
||||||
industry: json['industry']?.toString(),
|
industry: json['industry']?.toString(),
|
||||||
country: json['country']?.toString(),
|
country: json['country']?.toString(),
|
||||||
@@ -143,56 +269,62 @@ class FundamentalDataModel extends Equatable {
|
|||||||
currentPrice: parseDouble(json['currentPrice']),
|
currentPrice: parseDouble(json['currentPrice']),
|
||||||
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
|
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
|
||||||
dayChangePercent: parseDouble(json['dayChangePercent']),
|
dayChangePercent: parseDouble(json['dayChangePercent']),
|
||||||
fiftyTwoWeekHigh: parseDouble(json['fiftyTwoWeekHigh']),
|
fiftyTwoWeekHigh: parseDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
|
||||||
fiftyTwoWeekLow: parseDouble(json['fiftyTwoWeekLow']),
|
fiftyTwoWeekLow: parseDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
|
||||||
marketCapitalization: parseDouble(json['marketCapitalization'] ?? json['marketCap']),
|
marketCapitalization: parseDouble(fundMap?['marketCap'] ?? json['marketCapitalization'] ?? json['marketCap']),
|
||||||
enterpriseValue: parseDouble(json['enterpriseValue']),
|
enterpriseValue: parseDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']),
|
||||||
peRatioTrailing: parseNullableDouble(json['peRatioTrailing'] ?? json['peRatio']),
|
peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? json['peRatioTrailing'] ?? json['peRatio']),
|
||||||
peRatioForward: parseNullableDouble(json['peRatioForward']),
|
peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? json['peRatioForward']),
|
||||||
pegRatio: parseNullableDouble(json['pegRatio']),
|
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
|
||||||
pbRatio: parseNullableDouble(json['pbRatio']),
|
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? json['pbRatio']),
|
||||||
psRatio: parseNullableDouble(json['psRatio']),
|
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? json['psRatio']),
|
||||||
evToEbitda: parseNullableDouble(json['evToEbitda']),
|
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? json['evToEbitda']),
|
||||||
evToRevenue: parseNullableDouble(json['evToRevenue']),
|
evToRevenue: evToRevVal,
|
||||||
grossMargin: parseNullableDouble(json['grossMargin']),
|
totalRevenue: totalRev,
|
||||||
operatingMargin: parseNullableDouble(json['operatingMargin']),
|
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
|
||||||
netProfitMargin: parseNullableDouble(json['netProfitMargin']),
|
grossProfit: grossProf,
|
||||||
returnOnEquity: parseNullableDouble(json['returnOnEquity']),
|
ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']),
|
||||||
returnOnAssets: parseNullableDouble(json['returnOnAssets']),
|
dilutedEps: parseNullableDouble(fundMap?['dilutedEps'] ?? json['dilutedEps']),
|
||||||
returnOnInvestedCapital: parseNullableDouble(json['returnOnInvestedCapital']),
|
totalCash: parseNullableDouble(fundMap?['totalCash'] ?? json['totalCash']),
|
||||||
debtToEquity: parseNullableDouble(json['debtToEquity']),
|
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
|
||||||
currentRatio: parseNullableDouble(json['currentRatio']),
|
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
|
||||||
quickRatio: parseNullableDouble(json['quickRatio']),
|
freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
|
||||||
interestCoverage: parseNullableDouble(json['interestCoverage']),
|
grossMargin: grossMarginVal,
|
||||||
dividendYield: parseNullableDouble(json['dividendYield']),
|
operatingMargin: parseNullableDouble(fundMap?['operatingMargin'] ?? fundMap?['operatingIncome'] ?? json['operatingMargin']),
|
||||||
payoutRatio: parseNullableDouble(json['payoutRatio']),
|
netProfitMargin: parseNullableDouble(fundMap?['netProfitMargin'] ?? fundMap?['netIncome'] ?? json['netProfitMargin']),
|
||||||
exDividendDate: json['exDividendDate']?.toString(),
|
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
|
||||||
nextEarningsDate: json['nextEarningsDate']?.toString(),
|
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
|
||||||
percentHeldByInstitutions: parseNullableDouble(json['percentHeldByInstitutions']),
|
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
|
||||||
percentHeldByInsiders: parseNullableDouble(json['percentHeldByInsiders']),
|
debtToEquity: parseNullableDouble(fundMap?['debtToEquity'] ?? json['debtToEquity']),
|
||||||
shortRatio: parseNullableDouble(json['shortRatio']),
|
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
|
||||||
shortPercentOfFloat: parseNullableDouble(json['shortPercentOfFloat']),
|
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
|
||||||
consensusRating: json['consensusRating']?.toString(),
|
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
|
||||||
priceTargetLow: parseNullableDouble(json['priceTargetLow']),
|
dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? json['dividendYield']),
|
||||||
priceTargetHigh: parseNullableDouble(json['priceTargetHigh']),
|
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
|
||||||
priceTargetMedian: parseNullableDouble(json['priceTargetMedian']),
|
exDividendDate: exDividendDateVal,
|
||||||
priceTargetMean: parseNullableDouble(json['priceTargetMean']),
|
nextEarningsDate: nextEarningsDateVal,
|
||||||
|
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
|
||||||
|
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
|
||||||
|
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
|
||||||
|
shortPercentOfFloat: parseNullableDouble(fundMap?['shortPercentOfFloat'] ?? json['shortPercentOfFloat']),
|
||||||
|
consensusRating: (fundMap?['consensusRating'] ?? json['consensusRating'])?.toString(),
|
||||||
|
priceTargetLow: parseNullableDouble(fundMap?['priceTargetLow'] ?? json['priceTargetLow']),
|
||||||
|
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
|
||||||
|
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
|
||||||
|
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
|
||||||
executives: (json['executives'] as List?)
|
executives: (json['executives'] as List?)
|
||||||
?.map((e) => CompanyExecutiveModel.fromJson(e))
|
?.map((e) => CompanyExecutiveModel.fromJson(e is Map<String, dynamic> ? e : {}))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
financialStatements: (json['financialStatements'] as List?)
|
financialStatements: (json['financialStatements'] as List?)
|
||||||
?.map((e) => FinancialStatementModel.fromJson(e))
|
?.map((e) => FinancialStatementModel.fromJson(e is Map<String, dynamic> ? e : {}))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
estimates: (json['estimates'] as List?)
|
estimates: (json['estimates'] as List?)
|
||||||
?.map((e) => ForwardEstimateModel.fromJson(e))
|
?.map((e) => ForwardEstimateModel.fromJson(e is Map<String, dynamic> ? e : {}))
|
||||||
.toList() ??
|
|
||||||
[],
|
|
||||||
availableTickers: (json['availableTickers'] as List?)
|
|
||||||
?.map((e) => TickerModel.fromJson(e))
|
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
|
availableTickers: availableTickersList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,11 +454,30 @@ class CompanyExecutiveModel extends Equatable {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory CompanyExecutiveModel.fromJson(Map<String, dynamic> json) {
|
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(
|
return CompanyExecutiveModel(
|
||||||
name: json['name']?.toString() ?? '',
|
name: json['name']?.toString() ?? '',
|
||||||
title: json['title']?.toString() ?? '',
|
title: json['title']?.toString() ?? '',
|
||||||
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
|
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
|
||||||
compensation: json['compensation'] != null ? double.tryParse(json['compensation'].toString()) : null,
|
compensation: compVal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +696,7 @@ class TickerModel extends Equatable {
|
|||||||
required this.ticker,
|
required this.ticker,
|
||||||
this.exchange,
|
this.exchange,
|
||||||
this.tradingCurrency,
|
this.tradingCurrency,
|
||||||
required this.currentPrice,
|
this.currentPrice = 0.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory TickerModel.fromJson(Map<String, dynamic> json) {
|
factory TickerModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../../trades/models/trade_model.dart';
|
||||||
|
|
||||||
|
class ExecutionPlanModel extends Equatable {
|
||||||
|
final double stopLoss;
|
||||||
|
final List<double> takeProfitTargets;
|
||||||
|
final double riskRewardRatio;
|
||||||
|
final double maxLeverage;
|
||||||
|
|
||||||
|
const ExecutionPlanModel({
|
||||||
|
this.stopLoss = 0.0,
|
||||||
|
this.takeProfitTargets = const [],
|
||||||
|
this.riskRewardRatio = 0.0,
|
||||||
|
this.maxLeverage = 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExecutionPlanModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
double parseDbl(dynamic v) => (v as num?)?.toDouble() ?? 0.0;
|
||||||
|
return ExecutionPlanModel(
|
||||||
|
stopLoss: parseDbl(json['stopLoss']),
|
||||||
|
takeProfitTargets: (json['takeProfitTargets'] as List<dynamic>? ?? []).map((e) => parseDbl(e)).toList(),
|
||||||
|
riskRewardRatio: parseDbl(json['riskRewardRatio']),
|
||||||
|
maxLeverage: parseDbl(json['maxLeverage']) == 0 ? 1.0 : parseDbl(json['maxLeverage']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [stopLoss, takeProfitTargets, riskRewardRatio, maxLeverage];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DetailedAnalysisModel extends Equatable {
|
||||||
|
final String technicalRationale;
|
||||||
|
final String fundamentalRationale;
|
||||||
|
final String riskWarning;
|
||||||
|
|
||||||
|
const DetailedAnalysisModel({
|
||||||
|
this.technicalRationale = '',
|
||||||
|
this.fundamentalRationale = '',
|
||||||
|
this.riskWarning = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DetailedAnalysisModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DetailedAnalysisModel(
|
||||||
|
technicalRationale: json['technicalRationale']?.toString() ?? '',
|
||||||
|
fundamentalRationale: json['fundamentalRationale']?.toString() ?? '',
|
||||||
|
riskWarning: json['riskWarning']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [technicalRationale, fundamentalRationale, riskWarning];
|
||||||
|
}
|
||||||
|
|
||||||
|
class N8nAnalysisResponseDto extends Equatable {
|
||||||
|
final String aiDecision; // "Proceed", "Rejected", "Hold"
|
||||||
|
final String aiReasoning;
|
||||||
|
final int evalScore;
|
||||||
|
final String suggestedDirection; // "Long", "Short"
|
||||||
|
final String suggestedRisk;
|
||||||
|
final String suggestedTimeframe;
|
||||||
|
final ExecutionPlanModel? executionPlan;
|
||||||
|
final DetailedAnalysisModel? detailedAnalysis;
|
||||||
|
|
||||||
|
const N8nAnalysisResponseDto({
|
||||||
|
this.aiDecision = 'Rejected',
|
||||||
|
this.aiReasoning = '',
|
||||||
|
this.evalScore = 0,
|
||||||
|
this.suggestedDirection = 'Long',
|
||||||
|
this.suggestedRisk = 'Moderate',
|
||||||
|
this.suggestedTimeframe = '1D',
|
||||||
|
this.executionPlan,
|
||||||
|
this.detailedAnalysis,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory N8nAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
ExecutionPlanModel? execPlan;
|
||||||
|
if (json['executionPlan'] != null && json['executionPlan'] is Map<String, dynamic>) {
|
||||||
|
execPlan = ExecutionPlanModel.fromJson(json['executionPlan']);
|
||||||
|
}
|
||||||
|
|
||||||
|
DetailedAnalysisModel? detailAnalysis;
|
||||||
|
if (json['detailedAnalysis'] != null && json['detailedAnalysis'] is Map<String, dynamic>) {
|
||||||
|
detailAnalysis = DetailedAnalysisModel.fromJson(json['detailedAnalysis']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return N8nAnalysisResponseDto(
|
||||||
|
aiDecision: json['aiDecision']?.toString() ?? 'Rejected',
|
||||||
|
aiReasoning: json['aiReasoning']?.toString() ?? '',
|
||||||
|
evalScore: (json['evalScore'] as num?)?.toInt() ?? 0,
|
||||||
|
suggestedDirection: json['suggestedDirection']?.toString() ?? 'Long',
|
||||||
|
suggestedRisk: json['suggestedRisk']?.toString() ?? 'Moderate',
|
||||||
|
suggestedTimeframe: json['suggestedTimeframe']?.toString() ?? '1D',
|
||||||
|
executionPlan: execPlan,
|
||||||
|
detailedAnalysis: detailAnalysis,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
aiDecision,
|
||||||
|
aiReasoning,
|
||||||
|
evalScore,
|
||||||
|
suggestedDirection,
|
||||||
|
suggestedRisk,
|
||||||
|
suggestedTimeframe,
|
||||||
|
executionPlan,
|
||||||
|
detailedAnalysis,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ManualAnalysisResponseDto extends Equatable {
|
||||||
|
final String analysisId;
|
||||||
|
final bool isTradeProposed;
|
||||||
|
final String status;
|
||||||
|
final String recommendation; // "RECOMMENDED", "NOT_RECOMMENDED"
|
||||||
|
final N8nAnalysisResponseDto? n8nResponse;
|
||||||
|
final TradeModel? proposal;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ManualAnalysisResponseDto({
|
||||||
|
required this.analysisId,
|
||||||
|
this.isTradeProposed = false,
|
||||||
|
this.status = 'Success',
|
||||||
|
this.recommendation = 'NOT_RECOMMENDED',
|
||||||
|
this.n8nResponse,
|
||||||
|
this.proposal,
|
||||||
|
this.message = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ManualAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
N8nAnalysisResponseDto? n8n;
|
||||||
|
if (json['n8nResponse'] != null && json['n8nResponse'] is Map<String, dynamic>) {
|
||||||
|
n8n = N8nAnalysisResponseDto.fromJson(json['n8nResponse']);
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeModel? prop;
|
||||||
|
if (json['proposal'] != null && json['proposal'] is Map<String, dynamic>) {
|
||||||
|
prop = TradeModel.fromJson(json['proposal']);
|
||||||
|
} else if (n8n != null) {
|
||||||
|
final exec = n8n.executionPlan;
|
||||||
|
final det = n8n.detailedAnalysis;
|
||||||
|
final isProceed = n8n.aiDecision.toLowerCase() == 'proceed';
|
||||||
|
final analysisIdStr = (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '';
|
||||||
|
final tradeIdStr = 'PROP-${analysisIdStr.length > 10 ? analysisIdStr.substring(0, 10).toUpperCase() : 'MANUAL'}';
|
||||||
|
|
||||||
|
prop = TradeModel(
|
||||||
|
id: tradeIdStr,
|
||||||
|
analysisId: analysisIdStr,
|
||||||
|
symbol: (json['symbol'] ?? json['Symbol'])?.toString() ?? '',
|
||||||
|
isin: (json['isin'] ?? json['Isin'])?.toString() ?? '',
|
||||||
|
status: isProceed ? 'Proposed' : 'Rejected',
|
||||||
|
signalType: n8n.suggestedDirection.toUpperCase() == 'SHORT' ? 'SELL' : 'BUY',
|
||||||
|
entryPrice: 0.0,
|
||||||
|
stopLoss: exec?.stopLoss ?? 0.0,
|
||||||
|
takeProfit: (exec?.takeProfitTargets.isNotEmpty ?? false) ? exec!.takeProfitTargets.first : 0.0,
|
||||||
|
reasoning: n8n.aiReasoning,
|
||||||
|
technicalRationale: det?.technicalRationale ?? '',
|
||||||
|
fundamentalRationale: det?.fundamentalRationale ?? '',
|
||||||
|
riskWarning: det?.riskWarning ?? '',
|
||||||
|
takeProfitTargets: exec?.takeProfitTargets ?? const [],
|
||||||
|
maxLeverage: exec?.maxLeverage ?? 1.0,
|
||||||
|
riskTolerance: n8n.suggestedRisk,
|
||||||
|
timeframe: n8n.suggestedTimeframe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ManualAnalysisResponseDto(
|
||||||
|
analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '',
|
||||||
|
isTradeProposed: json['isTradeProposed'] == true || json['IsTradeProposed'] == true,
|
||||||
|
status: (json['status'] ?? json['Status'])?.toString() ?? 'Success',
|
||||||
|
recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'NOT_RECOMMENDED',
|
||||||
|
n8nResponse: n8n,
|
||||||
|
proposal: prop,
|
||||||
|
message: (json['message'] ?? json['Message'])?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [analysisId, isTradeProposed, status, recommendation, n8nResponse, proposal, message];
|
||||||
|
}
|
||||||
@@ -106,11 +106,16 @@ class StrategySignalModel extends Equatable {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
|
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawDir = (json['direction'] ?? json['signalType'] ?? json['type'])?.toString().toUpperCase() ?? 'BUY';
|
||||||
|
final sigDir = (rawDir == 'BUY' || rawDir == 'SELL') ? rawDir : 'BUY';
|
||||||
|
final sigTitle = (json['title'] ?? json['type'] ?? json['description'])?.toString() ?? 'Signal';
|
||||||
|
final dateStr = (json['timestamp'] ?? json['date'] ?? json['time'])?.toString();
|
||||||
|
|
||||||
return StrategySignalModel(
|
return StrategySignalModel(
|
||||||
title: json['title']?.toString() ?? '',
|
title: sigTitle,
|
||||||
date: DateTime.tryParse(json['date']?.toString() ?? '') ?? DateTime.now(),
|
date: dateStr != null ? (DateTime.tryParse(dateStr) ?? DateTime.now()) : DateTime.now(),
|
||||||
price: (json['price'] as num?)?.toDouble() ?? 0.0,
|
price: (json['price'] as num?)?.toDouble() ?? 0.0,
|
||||||
type: json['type']?.toString() ?? 'BUY',
|
type: sigDir,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,33 +128,76 @@ class PatternPoint extends Equatable {
|
|||||||
final double price;
|
final double price;
|
||||||
|
|
||||||
const PatternPoint(this.time, this.price);
|
const PatternPoint(this.time, this.price);
|
||||||
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble());
|
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time']?.toString() ?? '') ?? DateTime.now(), (json['price'] as num?)?.toDouble() ?? 0.0);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [time, price];
|
List<Object?> get props => [time, price];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChartPatternModel extends Equatable {
|
class BreakoutSignalModel extends Equatable {
|
||||||
final String type;
|
final String direction; // "UP", "DOWN"
|
||||||
final List<PatternPoint> upperLine;
|
final double targetPrice;
|
||||||
final List<PatternPoint> lowerLine;
|
final double potentialPercent;
|
||||||
|
|
||||||
const ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine});
|
const BreakoutSignalModel({
|
||||||
|
required this.direction,
|
||||||
|
required this.targetPrice,
|
||||||
|
required this.potentialPercent,
|
||||||
|
});
|
||||||
|
|
||||||
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
|
factory BreakoutSignalModel.fromJson(Map<String, dynamic> json) {
|
||||||
return ChartPatternModel(
|
return BreakoutSignalModel(
|
||||||
type: json['type']?.toString() ?? 'Pattern',
|
direction: (json['direction'] ?? json['Direction'])?.toString() ?? 'UP',
|
||||||
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
|
targetPrice: (json['targetPrice'] ?? json['TargetPrice'] as num?)?.toDouble() ?? 0.0,
|
||||||
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
|
potentialPercent: (json['potentialPercent'] ?? json['PotentialPercent'] as num?)?.toDouble() ?? 0.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [type, upperLine, lowerLine];
|
List<Object?> get props => [direction, targetPrice, potentialPercent];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChartPatternModel extends Equatable {
|
||||||
|
final String type;
|
||||||
|
final String description;
|
||||||
|
final double confidencePercent;
|
||||||
|
final BreakoutSignalModel? breakoutSignal;
|
||||||
|
final List<PatternPoint> upperLine;
|
||||||
|
final List<PatternPoint> lowerLine;
|
||||||
|
|
||||||
|
const ChartPatternModel({
|
||||||
|
required this.type,
|
||||||
|
this.description = '',
|
||||||
|
this.confidencePercent = 0.0,
|
||||||
|
this.breakoutSignal,
|
||||||
|
required this.upperLine,
|
||||||
|
required this.lowerLine,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
BreakoutSignalModel? breakout;
|
||||||
|
final bJson = json['breakoutSignal'] ?? json['BreakoutSignal'];
|
||||||
|
if (bJson != null && bJson is Map<String, dynamic>) {
|
||||||
|
breakout = BreakoutSignalModel.fromJson(bJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartPatternModel(
|
||||||
|
type: json['type']?.toString() ?? json['Type']?.toString() ?? 'Pattern',
|
||||||
|
description: json['description']?.toString() ?? json['Description']?.toString() ?? '',
|
||||||
|
confidencePercent: (json['confidencePercent'] ?? json['ConfidencePercent'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
breakoutSignal: breakout,
|
||||||
|
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [type, description, confidencePercent, breakoutSignal, upperLine, lowerLine];
|
||||||
}
|
}
|
||||||
|
|
||||||
class TechnicalAnalysisModel extends Equatable {
|
class TechnicalAnalysisModel extends Equatable {
|
||||||
final String symbol;
|
final String symbol;
|
||||||
|
final String currency;
|
||||||
final String trend;
|
final String trend;
|
||||||
final String rsi;
|
final String rsi;
|
||||||
final String macd;
|
final String macd;
|
||||||
@@ -167,6 +215,7 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
|
|
||||||
const TechnicalAnalysisModel({
|
const TechnicalAnalysisModel({
|
||||||
required this.symbol,
|
required this.symbol,
|
||||||
|
this.currency = 'EUR',
|
||||||
required this.trend,
|
required this.trend,
|
||||||
required this.rsi,
|
required this.rsi,
|
||||||
required this.macd,
|
required this.macd,
|
||||||
@@ -211,6 +260,7 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
|
|
||||||
return TechnicalAnalysisModel(
|
return TechnicalAnalysisModel(
|
||||||
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
|
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
|
||||||
|
currency: json['currency']?.toString() ?? 'EUR',
|
||||||
trend: parsedTrend,
|
trend: parsedTrend,
|
||||||
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
||||||
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
||||||
@@ -231,6 +281,7 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'symbol': symbol,
|
'symbol': symbol,
|
||||||
|
'currency': currency,
|
||||||
'trend': trend,
|
'trend': trend,
|
||||||
'rsi': rsi,
|
'rsi': rsi,
|
||||||
'macd': macd,
|
'macd': macd,
|
||||||
@@ -246,7 +297,7 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
symbol, trend, rsi, macd, overallSignal, sma50, sma200, vix,
|
symbol, currency, trend, rsi, macd, overallSignal, sma50, sma200, vix,
|
||||||
sp500Trend, dxy, stopLossAtr, candles, indicators, patterns, signals
|
sp500Trend, dxy, stopLossAtr, candles, indicators, patterns, signals
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:finlytic_app/core/network/api_client.dart';
|
|||||||
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
|
import 'package:finlytic_app/features/asset_detail/models/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/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';
|
||||||
|
|
||||||
@@ -58,13 +59,17 @@ class AssetRepository {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||||
try {
|
try {
|
||||||
final body = payload != null ? payload.toJson() : {'isin': isin};
|
final body = payload != null ? payload.toJson() : {'isin': isin};
|
||||||
await apiClient.post('/api/v1/analyze/manual', data: body);
|
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
||||||
|
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||||
|
return ManualAnalysisResponseDto.fromJson(res.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error triggering manual analysis for $isin: $e');
|
print('Error triggering manual analysis for $isin: $e');
|
||||||
throw e;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-50
@@ -37,7 +37,7 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -69,8 +69,6 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
forceRefresh: true,
|
forceRefresh: true,
|
||||||
exchange: _selectedExchange,
|
exchange: _selectedExchange,
|
||||||
ticker: _selectedTicker));
|
ticker: _selectedTicker));
|
||||||
// AssetFundamentalsBloc is omitted here because AssetHeaderBloc already triggers forceRefresh=true
|
|
||||||
// for fundamentals, and the listener below will fetch the updated data with forceRefresh=false.
|
|
||||||
context.read<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));
|
||||||
@@ -86,10 +84,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
if (_selectedTicker == null) {
|
if (_selectedTicker == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedTicker = widget.selectedTicker;
|
_selectedTicker = widget.selectedTicker;
|
||||||
//_selectedExchange = state.data!.exchange;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Re-trigger fundamentals and TA with resolved ticker whenever header loads (e.g. after force refresh)
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
||||||
widget.isin,
|
widget.isin,
|
||||||
ticker: _selectedTicker,
|
ticker: _selectedTicker,
|
||||||
@@ -100,16 +96,12 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
forceRefresh: false));
|
forceRefresh: false));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: LayoutBuilder(
|
child: SingleChildScrollView(
|
||||||
builder: (context, constraints) {
|
physics: const BouncingScrollPhysics(),
|
||||||
final height = constraints.maxHeight.isFinite
|
|
||||||
? constraints.maxHeight
|
|
||||||
: MediaQuery.of(context).size.height;
|
|
||||||
return SizedBox(
|
|
||||||
height: height,
|
|
||||||
width: double.infinity,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// 1. Hero Header
|
||||||
AssetHeroHeader(
|
AssetHeroHeader(
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
name: widget.name ?? widget.isin,
|
name: widget.name ?? widget.isin,
|
||||||
@@ -117,15 +109,10 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
onExchangeChanged: _handleExchangeChanged,
|
onExchangeChanged: _handleExchangeChanged,
|
||||||
onForceRefresh: _handleForceRefresh,
|
onForceRefresh: _handleForceRefresh,
|
||||||
),
|
),
|
||||||
Expanded(
|
|
||||||
child: Row(
|
// 2. Full-Width Interactive Chart Section
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Container(
|
||||||
children: [
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
// Left Panel (Chart Focus)
|
|
||||||
Expanded(
|
|
||||||
flex: 5,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.cardSurface,
|
color: theme.cardSurface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -134,21 +121,23 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
child: TechnicalTab(
|
child: TechnicalTab(
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
symbol: _selectedTicker,
|
symbol: _selectedTicker,
|
||||||
isDesktopLeftPanel: true),
|
showChartOnly: true,
|
||||||
|
chartHeight: 460,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Right Panel (Tabs for fundamentals/trades)
|
|
||||||
Expanded(
|
const SizedBox(height: 8),
|
||||||
flex: 3,
|
|
||||||
child: Container(
|
// 3. Detailed Sections & Fundamentals under the Chart
|
||||||
margin: const EdgeInsets.only(
|
Container(
|
||||||
top: 16, right: 16, bottom: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.cardSurface,
|
color: theme.cardSurface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: theme.glassBorder),
|
border: Border.all(color: theme.glassBorder),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
TabBar(
|
TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
@@ -159,34 +148,50 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
labelStyle: const TextStyle(
|
labelStyle: const TextStyle(
|
||||||
fontWeight: FontWeight.bold, fontSize: 13),
|
fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'OVERVIEW'),
|
Tab(
|
||||||
Tab(text: 'TRADES'),
|
icon: Icon(Icons.analytics_outlined, size: 18),
|
||||||
|
text: 'FUNDAMENTALS & ÜBERSICHT'),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.architecture_outlined, size: 18),
|
||||||
|
text: 'MUSTER & SIGNALE'),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
|
||||||
|
text: 'TRADES'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
AnimatedBuilder(
|
||||||
child: TabBarView(
|
animation: _tabController,
|
||||||
controller: _tabController,
|
builder: (context, _) {
|
||||||
children: [
|
switch (_tabController.index) {
|
||||||
FundamentalsTab(
|
case 0:
|
||||||
|
return FundamentalsTab(
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
symbol: _selectedTicker,
|
symbol: _selectedTicker,
|
||||||
),
|
isEmbedded: true,
|
||||||
TradesTab(symbol: widget.isin),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
case 1:
|
||||||
|
return TechnicalTab(
|
||||||
|
isin: widget.isin,
|
||||||
|
symbol: _selectedTicker,
|
||||||
|
showDetailsOnly: true,
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return SizedBox(
|
||||||
|
height: 600,
|
||||||
|
child: TradesTab(symbol: widget.isin),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,10 +84,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
if (_selectedTicker == null) {
|
if (_selectedTicker == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedTicker = widget.selectedTicker;
|
_selectedTicker = widget.selectedTicker;
|
||||||
//_selectedExchange = state.data!.exchange;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Re-trigger fundamentals and TA with resolved ticker whenever header loads (e.g. after force refresh)
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
||||||
widget.isin,
|
widget.isin,
|
||||||
ticker: _selectedTicker,
|
ticker: _selectedTicker,
|
||||||
@@ -98,82 +96,96 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
forceRefresh: false));
|
forceRefresh: false));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: NestedScrollView(
|
child: SingleChildScrollView(
|
||||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
physics: const BouncingScrollPhysics(),
|
||||||
return [
|
child: Column(
|
||||||
SliverToBoxAdapter(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: AssetHeroHeader(
|
children: [
|
||||||
|
// 1. Hero Header
|
||||||
|
AssetHeroHeader(
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
name: widget.name ?? widget.isin,
|
name: widget.name ?? widget.isin,
|
||||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||||
onExchangeChanged: _handleExchangeChanged,
|
onExchangeChanged: _handleExchangeChanged,
|
||||||
onForceRefresh: _handleForceRefresh,
|
onForceRefresh: _handleForceRefresh,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 2. Full-Width Interactive Chart Section
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardSurface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: theme.glassBorder),
|
||||||
),
|
),
|
||||||
SliverPersistentHeader(
|
child: TechnicalTab(
|
||||||
pinned: true,
|
isin: widget.isin,
|
||||||
delegate: _SliverAppBarDelegate(
|
symbol: _selectedTicker,
|
||||||
|
showChartOnly: true,
|
||||||
|
chartHeight: 330,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
|
||||||
|
// 3. Tab Bar & Detailed Sections (Fundamentals, Signals, Trades)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardSurface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: theme.glassBorder),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
TabBar(
|
TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
labelColor: theme.primaryColor,
|
labelColor: theme.primaryColor,
|
||||||
unselectedLabelColor: theme.textMuted,
|
unselectedLabelColor: theme.textMuted,
|
||||||
indicatorColor: theme.primaryColor,
|
indicatorColor: theme.primaryColor,
|
||||||
dividerColor: Colors.transparent,
|
dividerColor: theme.glassBorder,
|
||||||
labelStyle: const TextStyle(
|
labelStyle: const TextStyle(
|
||||||
fontWeight: FontWeight.bold, fontSize: 13),
|
fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'OVERVIEW'),
|
Tab(text: 'FUNDAMENTALS'),
|
||||||
Tab(text: 'TECHNICAL'),
|
Tab(text: 'MUSTER & SIGNALE'),
|
||||||
Tab(text: 'TRADES'),
|
Tab(text: 'TRADES'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
theme.cardSurface,
|
AnimatedBuilder(
|
||||||
),
|
animation: _tabController,
|
||||||
),
|
builder: (context, _) {
|
||||||
];
|
switch (_tabController.index) {
|
||||||
|
case 0:
|
||||||
|
return FundamentalsTab(
|
||||||
|
isin: widget.isin,
|
||||||
|
symbol: _selectedTicker,
|
||||||
|
isEmbedded: true,
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return TechnicalTab(
|
||||||
|
isin: widget.isin,
|
||||||
|
symbol: _selectedTicker,
|
||||||
|
showDetailsOnly: true,
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return SizedBox(
|
||||||
|
height: 500,
|
||||||
|
child: TradesTab(symbol: widget.isin),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
body: TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: [
|
|
||||||
FundamentalsTab(
|
|
||||||
isin: widget.isin,
|
|
||||||
symbol: _selectedTicker,
|
|
||||||
),
|
),
|
||||||
TechnicalTab(
|
],
|
||||||
isin: widget.isin,
|
|
||||||
symbol: _selectedTicker,
|
|
||||||
),
|
),
|
||||||
TradesTab(symbol: widget.isin),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
|
||||||
final TabBar _tabBar;
|
|
||||||
final Color _backgroundColor;
|
|
||||||
|
|
||||||
_SliverAppBarDelegate(this._tabBar, this._backgroundColor);
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get minExtent => _tabBar.preferredSize.height;
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get maxExtent => _tabBar.preferredSize.height;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(
|
|
||||||
BuildContext context, double shrinkOffset, bool overlapsContent) {
|
|
||||||
return Container(
|
|
||||||
color: _backgroundColor,
|
|
||||||
child: _tabBar,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../models/fundamental_data_model.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/status_badge.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 '../../utils/metric_explanations.dart';
|
import '../../utils/metric_explanations.dart';
|
||||||
|
|
||||||
class FundamentalsTab extends StatefulWidget {
|
class FundamentalsTab extends StatefulWidget {
|
||||||
final String isin;
|
final String isin;
|
||||||
final String? symbol;
|
final String? symbol;
|
||||||
const FundamentalsTab({super.key, this.symbol, required this.isin});
|
final bool isEmbedded;
|
||||||
|
|
||||||
|
const FundamentalsTab({
|
||||||
|
super.key,
|
||||||
|
this.symbol,
|
||||||
|
this.isEmbedded = false,
|
||||||
|
required this.isin,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FundamentalsTab> createState() => _FundamentalsTabState();
|
State<FundamentalsTab> createState() => _FundamentalsTabState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FundamentalsTabState extends State<FundamentalsTab> {
|
class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||||
String _selectedPeriodType = 'Annual'; // 'Annual' or 'Quarterly'
|
String _sym = '\$';
|
||||||
String _selectedStatementType = 'Income'; // 'Income', 'Balance', 'CashFlow'
|
String _curCode = 'USD';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -37,7 +45,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
|
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is AssetFundamentalsLoading) {
|
if (state is AssetFundamentalsLoading) {
|
||||||
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
return _buildFundamentalsShimmer(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AssetFundamentalsError) {
|
if (state is AssetFundamentalsError) {
|
||||||
@@ -64,11 +72,16 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
|
|
||||||
if (state is AssetFundamentalsLoaded) {
|
if (state is AssetFundamentalsLoaded) {
|
||||||
final data = state.data;
|
final data = state.data;
|
||||||
|
if (data != null) {
|
||||||
|
_sym = _getCurrencySymbol(data.ticker);
|
||||||
|
_curCode = _getCurrencyCode(data.ticker);
|
||||||
|
}
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return _buildEmptyState();
|
return _buildEmptyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -77,87 +90,11 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
_buildPriceTargetCard(data),
|
_buildPriceTargetCard(data),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 2. Valuation Multiples & Ratios
|
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
||||||
_buildSectionHeader('Bewertungskennzahlen & Multiples', Icons.analytics_outlined),
|
_buildCategoryPanels(data),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 20),
|
||||||
GridView.count(
|
|
||||||
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
|
|
||||||
crossAxisSpacing: 10,
|
|
||||||
mainAxisSpacing: 10,
|
|
||||||
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
children: [
|
|
||||||
_buildMetricCard('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
|
|
||||||
_buildMetricCard('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
|
|
||||||
_buildMetricCard('PEG Ratio', _fmtMultiple(data.pegRatio)),
|
|
||||||
_buildMetricCard('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
|
|
||||||
_buildMetricCard('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
|
|
||||||
_buildMetricCard('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
|
|
||||||
_buildMetricCard('EV / Sales', _fmtMultiple(data.evToRevenue)),
|
|
||||||
_buildMetricCard('Enterprise Value', _formatNumber(data.enterpriseValue)),
|
|
||||||
_buildMetricCard('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
|
|
||||||
_buildMetricCard('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
|
|
||||||
_buildMetricCard('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
|
|
||||||
_buildMetricCard('Short Ratio', _fmtMultiple(data.shortRatio)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 3. Profitability & Financial Health Margins
|
// 3. Company Description & Detailed Executive Board
|
||||||
_buildSectionHeader('Rentabilität & Finanzielle Gesundheit', Icons.account_balance_outlined),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
GridView.count(
|
|
||||||
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
|
|
||||||
crossAxisSpacing: 10,
|
|
||||||
mainAxisSpacing: 10,
|
|
||||||
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
children: [
|
|
||||||
_buildMetricCard('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
|
|
||||||
_buildMetricCard('Operative Marge', _fmtPercent(data.operatingMargin)),
|
|
||||||
_buildMetricCard('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
|
|
||||||
_buildMetricCard('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
|
|
||||||
_buildMetricCard('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
|
|
||||||
_buildMetricCard('ROIC (Invested Capital)', _fmtPercent(data.returnOnInvestedCapital)),
|
|
||||||
_buildMetricCard('Verschuldungsgrad (D/E)', _fmtMultiple(data.debtToEquity)),
|
|
||||||
_buildMetricCard('Current Ratio', _fmtMultiple(data.currentRatio)),
|
|
||||||
_buildMetricCard('Quick Ratio', _fmtMultiple(data.quickRatio)),
|
|
||||||
_buildMetricCard('Zinsdeckungsgrad', _fmtMultiple(data.interestCoverage)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 4. Dividends & Ownership
|
|
||||||
_buildSectionHeader('Dividenden & Aktionärsstruktur', Icons.pie_chart_outline),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
GridView.count(
|
|
||||||
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
|
|
||||||
crossAxisSpacing: 10,
|
|
||||||
mainAxisSpacing: 10,
|
|
||||||
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
children: [
|
|
||||||
_buildMetricCard('Dividendenrendite', _fmtPercent(data.dividendYield)),
|
|
||||||
_buildMetricCard('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
|
|
||||||
_buildMetricCard('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
|
|
||||||
_buildMetricCard('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
|
|
||||||
_buildMetricCard('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
|
|
||||||
_buildMetricCard('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
|
|
||||||
_buildMetricCard('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 5. Financial Statements Section
|
|
||||||
_buildSectionHeader('Finanzberichte (Statements)', Icons.article_outlined),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildStatementsSection(data),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 6. Company Description & Detailed Executive Board
|
|
||||||
_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),
|
_buildProfileSection(data),
|
||||||
@@ -219,176 +156,91 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatementsSection(FundamentalDataModel data) {
|
Widget _buildFundamentalsShimmer(BuildContext context) {
|
||||||
// Filter statements by Jährlich / Quartal
|
final isDesktop = MediaQuery.of(context).size.width >= 1050;
|
||||||
final filteredStatements = data.financialStatements
|
final isTablet = MediaQuery.of(context).size.width >= 680 && MediaQuery.of(context).size.width < 1050;
|
||||||
.where((s) => s.periodType.toLowerCase() == _selectedPeriodType.toLowerCase())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Sort descending by date
|
|
||||||
filteredStatements.sort((a, b) => b.endDate.compareTo(a.endDate));
|
|
||||||
|
|
||||||
|
Widget panelShimmer() {
|
||||||
return GlassContainer(
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ShimmerLoading(width: 180, height: 18, borderRadius: 6),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(color: Colors.white10, height: 1),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (int i = 0; i < 9; i++) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: const [
|
||||||
|
ShimmerLoading(width: 100, height: 14, borderRadius: 4),
|
||||||
|
ShimmerLoading(width: 60, height: 14, borderRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
physics: widget.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: [
|
||||||
// Row containing switches
|
// Price Target Card Shimmer
|
||||||
Row(
|
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
||||||
children: [
|
const SizedBox(height: 20),
|
||||||
// Period Toggle (Annual / Quarterly)
|
|
||||||
DropdownButton<String>(
|
|
||||||
value: _selectedPeriodType,
|
|
||||||
dropdownColor: AppTheme.cardSurface,
|
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
|
||||||
underline: const SizedBox.shrink(),
|
|
||||||
icon: const Icon(Icons.arrow_drop_down, color: Colors.white),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: 'Annual', child: Text('Jährlich (Annual)')),
|
|
||||||
DropdownMenuItem(value: 'Quarterly', child: Text('Quartal (Quarterly)')),
|
|
||||||
],
|
|
||||||
onChanged: (val) {
|
|
||||||
if (val != null) {
|
|
||||||
setState(() => _selectedPeriodType = val);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
// Statement Type Selector
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_buildStatementTabButton('GuV', 'Income'),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_buildStatementTabButton('Bilanz', 'Balance'),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_buildStatementTabButton('Cashflow', 'CashFlow'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(color: Colors.white10),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
if (filteredStatements.isEmpty)
|
// 3 Category Panels Shimmer
|
||||||
Padding(
|
if (isDesktop)
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
Row(
|
||||||
child: Center(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Text(
|
children: [
|
||||||
'Keine Berichte für diesen Typ vorhanden.',
|
Expanded(child: panelShimmer()),
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontStyle: FontStyle.italic),
|
const SizedBox(width: 14),
|
||||||
),
|
Expanded(child: panelShimmer()),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(child: panelShimmer()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else if (isTablet)
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: panelShimmer()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: panelShimmer()),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
panelShimmer(),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SingleChildScrollView(
|
Column(
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
physics: const BouncingScrollPhysics(),
|
|
||||||
child: Table(
|
|
||||||
defaultColumnWidth: const FixedColumnWidth(110),
|
|
||||||
columnWidths: const {
|
|
||||||
0: FixedColumnWidth(180), // First column containing label is wider
|
|
||||||
},
|
|
||||||
border: TableBorder(
|
|
||||||
horizontalInside: BorderSide(color: Colors.white, width: 0.5),
|
|
||||||
),
|
|
||||||
children: _buildTableRows(filteredStatements),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStatementTabButton(String label, String typeCode) {
|
|
||||||
final isSelected = _selectedStatementType == typeCode;
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => setState(() => _selectedStatementType = typeCode),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : Colors.white10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isSelected ? AppTheme.primaryEmerald : Colors.white70,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<TableRow> _buildTableRows(List<FinancialStatementModel> statements) {
|
|
||||||
final List<TableRow> rows = [];
|
|
||||||
|
|
||||||
// Header row containing Dates
|
|
||||||
rows.add(
|
|
||||||
TableRow(
|
|
||||||
children: [
|
children: [
|
||||||
_buildTableCell('Kennzahl (in EUR)', isHeader: true),
|
panelShimmer(),
|
||||||
...statements.map((s) => _buildTableCell(_fmtDate(s.endDate), isHeader: true)),
|
const SizedBox(height: 12),
|
||||||
|
panelShimmer(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
panelShimmer(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
|
|
||||||
if (_selectedStatementType == 'Income') {
|
const SizedBox(height: 20),
|
||||||
rows.add(_buildDataRow('Umsatzerlöse', statements.map((s) => s.totalRevenue).toList()));
|
// Profile Section Shimmer
|
||||||
rows.add(_buildDataRow('Umsatzkosten', statements.map((s) => s.costOfRevenue).toList()));
|
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
|
||||||
rows.add(_buildDataRow('Bruttogewinn', statements.map((s) => s.grossProfit).toList()));
|
const SizedBox(height: 12),
|
||||||
rows.add(_buildDataRow('Operative Aufwendungen', statements.map((s) => s.operatingExpenses).toList()));
|
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
|
||||||
rows.add(_buildDataRow('Operatives Ergebnis (EBIT)', statements.map((s) => s.operatingIncome).toList()));
|
|
||||||
rows.add(_buildDataRow('EBITDA', statements.map((s) => s.ebitda).toList()));
|
|
||||||
rows.add(_buildDataRow('Jahresüberschuss', statements.map((s) => s.netIncome).toList()));
|
|
||||||
rows.add(_buildDataRow('EPS (Basic)', statements.map((s) => s.epsBasic).toList(), isCurrency: true));
|
|
||||||
rows.add(_buildDataRow('EPS (Diluted)', statements.map((s) => s.epsDiluted).toList(), isCurrency: true));
|
|
||||||
} else if (_selectedStatementType == 'Balance') {
|
|
||||||
rows.add(_buildDataRow('Liquide Mittel', statements.map((s) => s.cashAndCashEquivalents).toList()));
|
|
||||||
rows.add(_buildDataRow('Forderungen', statements.map((s) => s.accountsReceivable).toList()));
|
|
||||||
rows.add(_buildDataRow('Vorräte', statements.map((s) => s.inventory).toList()));
|
|
||||||
rows.add(_buildDataRow('Umlaufvermögen (Current Assets)', statements.map((s) => s.totalCurrentAssets).toList()));
|
|
||||||
rows.add(_buildDataRow('Anlagevermögen (Non-Current)', statements.map((s) => s.totalNonCurrentAssets).toList()));
|
|
||||||
rows.add(_buildDataRow('Kurzfr. Verbindlichkeiten', statements.map((s) => s.currentLiabilities).toList()));
|
|
||||||
rows.add(_buildDataRow('Langfristige Schulden', statements.map((s) => s.longTermDebt).toList()));
|
|
||||||
rows.add(_buildDataRow('Gesamtverbindlichkeiten', statements.map((s) => s.totalLiabilities).toList()));
|
|
||||||
rows.add(_buildDataRow('Eigenkapital (Equity)', statements.map((s) => s.totalStockholdersEquity).toList()));
|
|
||||||
} else {
|
|
||||||
rows.add(_buildDataRow('Operativer Cashflow', statements.map((s) => s.operatingCashFlow).toList()));
|
|
||||||
rows.add(_buildDataRow('Investiver Cashflow', statements.map((s) => s.investingCashFlow).toList()));
|
|
||||||
rows.add(_buildDataRow('Investitionsausgaben (CapEx)', statements.map((s) => s.capitalExpenditures).toList()));
|
|
||||||
rows.add(_buildDataRow('Finanzierungs-Cashflow', statements.map((s) => s.financingCashFlow).toList()));
|
|
||||||
rows.add(_buildDataRow('Free Cashflow', statements.map((s) => s.freeCashFlow).toList()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
TableRow _buildDataRow(String label, List<dynamic> values, {bool isCurrency = false}) {
|
|
||||||
return TableRow(
|
|
||||||
children: [
|
|
||||||
_buildTableCell(label),
|
|
||||||
...values.map((v) => _buildTableCell(isCurrency ? _fmtCurrency(v) : _formatNumber(v))),
|
|
||||||
],
|
],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTableCell(String val, {bool isHeader = false}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
|
||||||
child: Text(
|
|
||||||
val,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isHeader ? AppTheme.accentCyan : Colors.white70,
|
|
||||||
fontWeight: isHeader ? FontWeight.bold : FontWeight.normal,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -532,43 +384,197 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetricCard(String label, String value) {
|
Widget _buildCategoryPanels(FundamentalDataModel data) {
|
||||||
return InkWell(
|
final valuationItems = [
|
||||||
onTap: () => MetricExplanations.show(context, label),
|
_MetricRowItem('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
|
||||||
borderRadius: BorderRadius.circular(10),
|
_MetricRowItem('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
|
||||||
child: GlassContainer(
|
_MetricRowItem('PEG Ratio', _fmtMultiple(data.pegRatio)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
_MetricRowItem('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
|
||||||
child: Column(
|
_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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
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: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
title,
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Divider(color: Colors.white10, height: 1),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Expanded(
|
...items.asMap().entries.map((entry) {
|
||||||
child: Align(
|
final idx = entry.key;
|
||||||
alignment: Alignment.centerLeft,
|
final item = entry.value;
|
||||||
child: FittedBox(
|
final isEven = idx % 2 == 0;
|
||||||
fit: BoxFit.scaleDown,
|
return _buildMetricListRow(item.label, item.value, isEven: isEven, valueColor: item.valueColor);
|
||||||
alignment: Alignment.centerLeft,
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
|
style: TextStyle(
|
||||||
),
|
color: valueColor ?? (value == 'N/A' ? AppTheme.textMuted : Colors.white),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -583,18 +589,38 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
return n != null ? '${n.toStringAsFixed(2)}x' : 'N/A';
|
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) {
|
String _fmtPercent(dynamic val) {
|
||||||
if (val == null) return 'N/A';
|
if (val == null) return 'N/A';
|
||||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||||
if (n == null) return 'N/A';
|
if (n == null) return 'N/A';
|
||||||
final p = (n > 0 && n <= 1) ? n * 100 : n;
|
// 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)} %';
|
return '${p.toStringAsFixed(2)} %';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fmtCurrency(dynamic val) {
|
String _fmtCurrency(dynamic val) {
|
||||||
if (val == null) return 'N/A';
|
if (val == null) return 'N/A';
|
||||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||||
return n != null ? '€${n.toStringAsFixed(2)}' : 'N/A';
|
if (n == null || n == 0) return 'N/A';
|
||||||
|
return '$_sym${n.toStringAsFixed(2)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fmtDate(dynamic val) {
|
String _fmtDate(dynamic val) {
|
||||||
@@ -610,18 +636,62 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
|
|
||||||
final isNegative = n < 0;
|
final isNegative = n < 0;
|
||||||
final absVal = n.abs();
|
final absVal = n.abs();
|
||||||
final prefix = isNegative ? '-€' : '€';
|
final prefix = isNegative ? '-$_sym' : _sym;
|
||||||
|
|
||||||
if (absVal >= 1e12) {
|
if (absVal >= 1e12) {
|
||||||
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bil.';
|
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bio.';
|
||||||
} else if (absVal >= 1e9) {
|
} else if (absVal >= 1e9) {
|
||||||
return '$prefix${(absVal / 1e9).toStringAsFixed(2)} Mrd.';
|
return '$prefix${(absVal / 1e9).toStringAsFixed(2)} Mrd.';
|
||||||
} else if (absVal >= 1e6) {
|
} else if (absVal >= 1e6) {
|
||||||
return '$prefix${(absVal / 1e6).toStringAsFixed(2)} Mio.';
|
return '$prefix${(absVal / 1e6).toStringAsFixed(2)} Mio.';
|
||||||
} else if (absVal >= 1e3) {
|
} else if (absVal >= 1e3) {
|
||||||
return '$prefix${(absVal / 1e3).toStringAsFixed(2)} Tsd.';
|
return '$prefix${(absVal / 1e3).toStringAsFixed(1)} Tsd.';
|
||||||
} else {
|
} else {
|
||||||
return '$prefix${absVal.toStringAsFixed(2)}';
|
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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
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/status_badge.dart';
|
import '../../../../core/widgets/status_badge.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';
|
||||||
@@ -15,11 +16,17 @@ class TechnicalTab extends StatefulWidget {
|
|||||||
final String isin;
|
final String isin;
|
||||||
final String? symbol;
|
final String? symbol;
|
||||||
final bool isDesktopLeftPanel;
|
final bool isDesktopLeftPanel;
|
||||||
|
final bool showChartOnly;
|
||||||
|
final bool showDetailsOnly;
|
||||||
|
final double chartHeight;
|
||||||
|
|
||||||
const TechnicalTab({
|
const TechnicalTab({
|
||||||
super.key,
|
super.key,
|
||||||
this.symbol,
|
this.symbol,
|
||||||
this.isDesktopLeftPanel = false,
|
this.isDesktopLeftPanel = false,
|
||||||
|
this.showChartOnly = false,
|
||||||
|
this.showDetailsOnly = false,
|
||||||
|
this.chartHeight = 420,
|
||||||
required this.isin,
|
required this.isin,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,8 +60,7 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is AssetTechnicalLoading) {
|
if (state is AssetTechnicalLoading) {
|
||||||
return Center(
|
return _buildTechnicalShimmer(context);
|
||||||
child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AssetTechnicalError) {
|
if (state is AssetTechnicalError) {
|
||||||
@@ -132,14 +138,8 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
if (!_disabledPatternIndices.contains(i)) patterns[i]
|
if (!_disabledPatternIndices.contains(i)) patterns[i]
|
||||||
];
|
];
|
||||||
|
|
||||||
return SingleChildScrollView(
|
final chartRibbon = GlassContainer(
|
||||||
child: Column(
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Glassmorphic Indicator & Pattern Control Ribbon
|
|
||||||
GlassContainer(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -182,12 +182,11 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Interactive Candlestick Chart
|
final chartWidget = SizedBox(
|
||||||
SizedBox(
|
height: widget.chartHeight,
|
||||||
height: 380,
|
width: double.infinity,
|
||||||
child: CandlestickChart(
|
child: CandlestickChart(
|
||||||
candles: candles,
|
candles: candles,
|
||||||
patterns: activePatterns,
|
patterns: activePatterns,
|
||||||
@@ -200,11 +199,21 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
showSignals: _showSignals,
|
showSignals: _showSignals,
|
||||||
showSupertrend: _showSupertrend,
|
showSupertrend: _showSupertrend,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Dedicated Chart Patterns & Signal Description List Section
|
if (widget.showChartOnly) {
|
||||||
Padding(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
chartRibbon,
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
chartWidget,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final detailsSection = Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -291,7 +300,24 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
if (widget.showDetailsOnly) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: detailsSection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
chartRibbon,
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
chartWidget,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
detailsSection,
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -506,4 +532,54 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildTechnicalShimmer(BuildContext context) {
|
||||||
|
if (widget.showChartOnly) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.showDetailsOnly) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
for (int i = 0; i < 4; i++) ...[
|
||||||
|
const ShimmerLoading(width: double.infinity, height: 68, borderRadius: 12),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
for (int i = 0; i < 3; i++) ...[
|
||||||
|
const ShimmerLoading(width: double.infinity, height: 68, borderRadius: 12),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
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/status_badge.dart';
|
import '../../../../core/widgets/status_badge.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
||||||
|
|
||||||
@@ -553,7 +554,7 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
if (state is AssetTradesLoading)
|
if (state is AssetTradesLoading)
|
||||||
Center(child: Padding(padding: const EdgeInsets.all(32), child: CircularProgressIndicator(color: AppTheme.primaryEmerald)))
|
_buildTradesShimmer(context)
|
||||||
else if (state is AssetTradesError)
|
else if (state is AssetTradesError)
|
||||||
GlassContainer(
|
GlassContainer(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -583,6 +584,20 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildTradesShimmer(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ShimmerLoading(width: 260, height: 20, borderRadius: 6),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (int i = 0; i < 3; i++) ...[
|
||||||
|
const ShimmerLoading(width: double.infinity, height: 105, borderRadius: 14),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTradeList(String title, List<TradeModel> trades) {
|
Widget _buildTradeList(String title, List<TradeModel> trades) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -649,7 +664,7 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header Row: Side, Status, Instrument, Action Buttons
|
// Header Row: Side, Status, Instrument, Action Buttons cv
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -168,12 +168,30 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
return Listener(
|
return Listener(
|
||||||
onPointerSignal: (pointerSignal) {
|
onPointerSignal: (pointerSignal) {
|
||||||
if (pointerSignal is PointerScrollEvent) {
|
if (pointerSignal is PointerScrollEvent) {
|
||||||
|
GestureBinding.instance.pointerSignalResolver.register(
|
||||||
|
pointerSignal,
|
||||||
|
(event) {
|
||||||
|
if (event is PointerScrollEvent) {
|
||||||
setState(() {
|
setState(() {
|
||||||
final double zoomFactor = pointerSignal.scrollDelta.dy > 0 ? 0.9 : 1.1;
|
final double localX = event.localPosition.dx;
|
||||||
_scale = (_scale * zoomFactor).clamp(0.2, 5.0);
|
final double zoomFactor = event.scrollDelta.dy > 0 ? 0.9 : 1.1;
|
||||||
|
final double newScale = (_scale * zoomFactor).clamp(0.2, 5.0);
|
||||||
|
final double scaleRatio = newScale / _scale;
|
||||||
|
|
||||||
|
// Zoom centered on cursor
|
||||||
|
_panOffset = localX - (localX - _panOffset) * scaleRatio;
|
||||||
|
_scale = newScale;
|
||||||
|
|
||||||
|
final double updatedCandleSpace = (baseWidth + spacing) * _scale;
|
||||||
|
final double updatedContentWidth = (widget.candles.length + 15) * updatedCandleSpace;
|
||||||
|
final double newMinOffset = constraints.maxWidth - updatedContentWidth - 60.0;
|
||||||
|
_panOffset = _panOffset.clamp(newMinOffset < maxOffset ? newMinOffset : maxOffset, maxOffset);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onScaleUpdate: (details) {
|
onScaleUpdate: (details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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/header/asset_header_bloc.dart';
|
||||||
import '../../bloc/header/asset_header_state.dart';
|
import '../../bloc/header/asset_header_state.dart';
|
||||||
|
import '../../bloc/technical/asset_technical_bloc.dart';
|
||||||
|
import '../../bloc/technical/asset_technical_state.dart';
|
||||||
import '../../models/asset_model.dart';
|
import '../../models/asset_model.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -146,7 +148,26 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||||
|
builder: (context, taState) {
|
||||||
|
double? livePrice = price;
|
||||||
|
String liveCurrency = selectedOption.tradingCurrency.isNotEmpty
|
||||||
|
? selectedOption.tradingCurrency
|
||||||
|
: currency;
|
||||||
|
|
||||||
|
if (taState is AssetTechnicalLoaded && taState.data != null) {
|
||||||
|
if (taState.data!.candles.isNotEmpty) {
|
||||||
|
final lastClose = taState.data!.candles.last.close;
|
||||||
|
if (lastClose > 0) {
|
||||||
|
livePrice = lastClose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (taState.data!.currency.isNotEmpty) {
|
||||||
|
liveCurrency = taState.data!.currency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@@ -163,7 +184,7 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
SelectableText(
|
SelectableText(
|
||||||
price != null && price > 0 ? price.toStringAsFixed(2) : '---',
|
livePrice != null && livePrice > 0 ? livePrice.toStringAsFixed(2) : '---',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -174,7 +195,7 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
selectedOption.tradingCurrency.isNotEmpty ? selectedOption.tradingCurrency : currency,
|
liveCurrency,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -185,6 +206,8 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
// Interactive Ticker & Exchange Selector Dropdown
|
// Interactive Ticker & Exchange Selector Dropdown
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
class MatchedAssetModel extends Equatable {
|
||||||
|
final String isin;
|
||||||
|
final String symbol;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
const MatchedAssetModel({
|
||||||
|
required this.isin,
|
||||||
|
required this.symbol,
|
||||||
|
required this.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MatchedAssetModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MatchedAssetModel(
|
||||||
|
isin: (json['isin'] ?? json['Isin'])?.toString() ?? '',
|
||||||
|
symbol: (json['symbol'] ?? json['Symbol'] ?? json['ticker'] ?? json['Ticker'])?.toString() ?? '',
|
||||||
|
name: (json['name'] ?? json['Name'] ?? json['companyName'] ?? json['CompanyName'])?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'isin': isin,
|
||||||
|
'symbol': symbol,
|
||||||
|
'name': name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [isin, symbol, name];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'finbert_result_model.dart';
|
import 'finbert_result_model.dart';
|
||||||
|
import 'matched_asset_model.dart';
|
||||||
|
|
||||||
class NewsArticleModel extends Equatable {
|
class NewsArticleModel extends Equatable {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -17,6 +18,7 @@ class NewsArticleModel extends Equatable {
|
|||||||
final double sentimentScore;
|
final double sentimentScore;
|
||||||
final double confidence;
|
final double confidence;
|
||||||
final FinbertResultModel? finbertResult;
|
final FinbertResultModel? finbertResult;
|
||||||
|
final List<MatchedAssetModel> matchedAssets;
|
||||||
|
|
||||||
const NewsArticleModel({
|
const NewsArticleModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -32,9 +34,16 @@ class NewsArticleModel extends Equatable {
|
|||||||
required this.sentimentScore,
|
required this.sentimentScore,
|
||||||
required this.confidence,
|
required this.confidence,
|
||||||
this.finbertResult,
|
this.finbertResult,
|
||||||
|
this.matchedAssets = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
|
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
List<MatchedAssetModel> assets = [];
|
||||||
|
final mList = json['matchedAssets'] ?? json['MatchedAssets'];
|
||||||
|
if (mList != null && mList is List) {
|
||||||
|
assets = mList.map((e) => MatchedAssetModel.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
return NewsArticleModel(
|
return NewsArticleModel(
|
||||||
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
|
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
|
||||||
title: json['title']?.toString() ?? json['Title']?.toString() ?? 'No Title',
|
title: json['title']?.toString() ?? json['Title']?.toString() ?? 'No Title',
|
||||||
@@ -52,6 +61,7 @@ class NewsArticleModel extends Equatable {
|
|||||||
finbertResult: (json['finbertResult'] != null || json['FinbertResult'] != null)
|
finbertResult: (json['finbertResult'] != null || json['FinbertResult'] != null)
|
||||||
? FinbertResultModel.fromJson(json['finbertResult'] ?? json['FinbertResult'])
|
? FinbertResultModel.fromJson(json['finbertResult'] ?? json['FinbertResult'])
|
||||||
: null,
|
: null,
|
||||||
|
matchedAssets: assets,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class TradeAcceptanceDto {
|
|||||||
final double? stopLoss;
|
final double? stopLoss;
|
||||||
final double? takeProfit;
|
final double? takeProfit;
|
||||||
final String? instrumentType;
|
final String? instrumentType;
|
||||||
|
final String? derivativeIsin;
|
||||||
final String? timeframe;
|
final String? timeframe;
|
||||||
final String? reasoning;
|
final String? reasoning;
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ class TradeAcceptanceDto {
|
|||||||
this.stopLoss,
|
this.stopLoss,
|
||||||
this.takeProfit,
|
this.takeProfit,
|
||||||
this.instrumentType,
|
this.instrumentType,
|
||||||
|
this.derivativeIsin,
|
||||||
this.timeframe,
|
this.timeframe,
|
||||||
this.reasoning,
|
this.reasoning,
|
||||||
});
|
});
|
||||||
@@ -67,6 +69,7 @@ class TradeAcceptanceDto {
|
|||||||
'stopLoss': stopLoss,
|
'stopLoss': stopLoss,
|
||||||
'takeProfit': takeProfit,
|
'takeProfit': takeProfit,
|
||||||
'instrumentType': instrumentType,
|
'instrumentType': instrumentType,
|
||||||
|
'derivativeIsin': derivativeIsin,
|
||||||
'timeframe': timeframe,
|
'timeframe': timeframe,
|
||||||
'reasoning': reasoning,
|
'reasoning': reasoning,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class TradeModel extends Equatable {
|
|||||||
final double winRate;
|
final double winRate;
|
||||||
final String timeframe;
|
final String timeframe;
|
||||||
final String instrumentType;
|
final String instrumentType;
|
||||||
|
final String derivativeIsin;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
|
|
||||||
final String riskTolerance;
|
final String riskTolerance;
|
||||||
@@ -67,6 +68,7 @@ class TradeModel extends Equatable {
|
|||||||
this.winRate = 50.0,
|
this.winRate = 50.0,
|
||||||
this.timeframe = '1D',
|
this.timeframe = '1D',
|
||||||
this.instrumentType = 'Stock',
|
this.instrumentType = 'Stock',
|
||||||
|
this.derivativeIsin = '',
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.riskTolerance = 'Moderate',
|
this.riskTolerance = 'Moderate',
|
||||||
this.vixValue = 0.0,
|
this.vixValue = 0.0,
|
||||||
@@ -92,21 +94,45 @@ class TradeModel extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double get calculatedPnlAbs {
|
double get calculatedPnlAbs {
|
||||||
if (pnlAbsolute != 0) return pnlAbsolute;
|
if (isClosed && pnlAbsolute != 0) return pnlAbsolute;
|
||||||
|
final curr = currentPrice;
|
||||||
|
if (curr <= 0) return pnlAbsolute;
|
||||||
final entry = actualEntryPrice > 0 ? actualEntryPrice : entryPrice;
|
final entry = actualEntryPrice > 0 ? actualEntryPrice : entryPrice;
|
||||||
final curr = effectiveCurrentPrice;
|
|
||||||
if (entry <= 0) return 0.0;
|
if (entry <= 0) return 0.0;
|
||||||
final isShort = signalType == 'SELL' || signalType == 'SHORT';
|
final isShort = signalType == 'SELL' || signalType == 'SHORT';
|
||||||
final rawMove = isShort ? ((entry - curr) / entry) : ((curr - entry) / entry);
|
final rawMove = isShort ? ((entry - curr) / entry) : ((curr - entry) / entry);
|
||||||
final posSize = positionSize > 0 ? positionSize : entry;
|
final posSize = positionSize > 0 ? positionSize : (quantity > 0 ? quantity * entry : entry);
|
||||||
final lev = leverageUsed > 0 ? leverageUsed : 1.0;
|
final lev = leverageUsed > 0 ? leverageUsed : 1.0;
|
||||||
return (rawMove * posSize * lev);
|
final fees = entryFee + exitFee;
|
||||||
|
return (rawMove * posSize * lev) - fees;
|
||||||
}
|
}
|
||||||
|
|
||||||
double get calculatedPnlPct {
|
double get calculatedPnlPct {
|
||||||
if (pnlPercent != 0) return pnlPercent;
|
if (isClosed && pnlPercent != 0) return pnlPercent;
|
||||||
final pnlAbs = calculatedPnlAbs;
|
final pnlAbs = calculatedPnlAbs;
|
||||||
final posSize = positionSize > 0 ? positionSize : (actualEntryPrice > 0 ? actualEntryPrice : entryPrice);
|
final posSize = positionSize > 0 ? positionSize : (actualEntryPrice > 0 ? actualEntryPrice : (entryPrice > 0 ? entryPrice : 1.0));
|
||||||
|
if (posSize <= 0) return 0.0;
|
||||||
|
return (pnlAbs / posSize) * 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double calculateLivePnlAbs(double livePrice) {
|
||||||
|
if (isClosed && pnlAbsolute != 0) return pnlAbsolute;
|
||||||
|
final curr = livePrice > 0 ? livePrice : currentPrice;
|
||||||
|
if (curr <= 0) return pnlAbsolute;
|
||||||
|
final entry = actualEntryPrice > 0 ? actualEntryPrice : entryPrice;
|
||||||
|
if (entry <= 0) return 0.0;
|
||||||
|
final isShort = signalType == 'SELL' || signalType == 'SHORT';
|
||||||
|
final rawMove = isShort ? ((entry - curr) / entry) : ((curr - entry) / entry);
|
||||||
|
final posSize = positionSize > 0 ? positionSize : (quantity > 0 ? quantity * entry : entry);
|
||||||
|
final lev = leverageUsed > 0 ? leverageUsed : 1.0;
|
||||||
|
final fees = entryFee + exitFee;
|
||||||
|
return (rawMove * posSize * lev) - fees;
|
||||||
|
}
|
||||||
|
|
||||||
|
double calculateLivePnlPct(double livePrice) {
|
||||||
|
if (isClosed && pnlPercent != 0) return pnlPercent;
|
||||||
|
final pnlAbs = calculateLivePnlAbs(livePrice);
|
||||||
|
final posSize = positionSize > 0 ? positionSize : (actualEntryPrice > 0 ? actualEntryPrice : (entryPrice > 0 ? entryPrice : 1.0));
|
||||||
if (posSize <= 0) return 0.0;
|
if (posSize <= 0) return 0.0;
|
||||||
return (pnlAbs / posSize) * 100.0;
|
return (pnlAbs / posSize) * 100.0;
|
||||||
}
|
}
|
||||||
@@ -161,6 +187,7 @@ class TradeModel extends Equatable {
|
|||||||
winRate: parseDbl(json['winRate'] ?? json['WinRate']),
|
winRate: parseDbl(json['winRate'] ?? json['WinRate']),
|
||||||
timeframe: (json['timeframe'] ?? json['Timeframe'])?.toString() ?? '1D',
|
timeframe: (json['timeframe'] ?? json['Timeframe'])?.toString() ?? '1D',
|
||||||
instrumentType: (json['instrumentType'] ?? json['InstrumentType'])?.toString() ?? 'Stock',
|
instrumentType: (json['instrumentType'] ?? json['InstrumentType'])?.toString() ?? 'Stock',
|
||||||
|
derivativeIsin: (json['derivativeIsin'] ?? json['DerivativeIsin'] ?? json['knockoutIsin'] ?? json['KnockoutIsin'])?.toString() ?? '',
|
||||||
createdAt: dt,
|
createdAt: dt,
|
||||||
riskTolerance: (json['riskTolerance'] ?? json['RiskTolerance'])?.toString() ?? 'Moderate',
|
riskTolerance: (json['riskTolerance'] ?? json['RiskTolerance'])?.toString() ?? 'Moderate',
|
||||||
vixValue: parseDbl(json['vixValue'] ?? json['VixValue']),
|
vixValue: parseDbl(json['vixValue'] ?? json['VixValue']),
|
||||||
@@ -205,6 +232,7 @@ class TradeModel extends Equatable {
|
|||||||
'winRate': winRate,
|
'winRate': winRate,
|
||||||
'timeframe': timeframe,
|
'timeframe': timeframe,
|
||||||
'instrumentType': instrumentType,
|
'instrumentType': instrumentType,
|
||||||
|
'derivativeIsin': derivativeIsin,
|
||||||
'createdAt': createdAt?.toIso8601String(),
|
'createdAt': createdAt?.toIso8601String(),
|
||||||
'riskTolerance': riskTolerance,
|
'riskTolerance': riskTolerance,
|
||||||
'vixValue': vixValue,
|
'vixValue': vixValue,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ class TradeRepository {
|
|||||||
|
|
||||||
Future<List<TradeModel>> fetchTrades({String? isin, String? status}) async {
|
Future<List<TradeModel>> fetchTrades({String? isin, String? status}) async {
|
||||||
try {
|
try {
|
||||||
final queryParams = <String, dynamic>{};
|
final queryParams = <String, dynamic>{
|
||||||
|
'_t': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin;
|
if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin;
|
||||||
if (status != null && status.isNotEmpty) queryParams['status'] = status;
|
if (status != null && status.isNotEmpty) queryParams['status'] = status;
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class _TradesFeedScreenContent extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||||
String _selectedFilter = 'Alle'; // 'Alle', 'Offen', 'Vorschläge', 'Geschlossen'
|
String _selectedFilter = 'Offen'; // 'Alle', 'Offen', 'Vorschläge', 'Geschlossen'
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
final TextEditingController _searchCtrl = TextEditingController();
|
final TextEditingController _searchCtrl = TextEditingController();
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
|||||||
final rejectedTrades = allTrades.where((t) => t.isRejected).toList();
|
final rejectedTrades = allTrades.where((t) => t.isRejected).toList();
|
||||||
|
|
||||||
// Performance Header Calculations
|
// Performance Header Calculations
|
||||||
final totalOpenPnlAbs = activeTrades.fold<double>(0, (sum, t) => sum + t.pnlAbsolute);
|
final totalOpenPnlAbs = activeTrades.fold<double>(0, (sum, t) => sum + t.calculatedPnlAbs);
|
||||||
final isPnlPos = totalOpenPnlAbs >= 0;
|
final isPnlPos = totalOpenPnlAbs >= 0;
|
||||||
final winRatePct = allTrades.isNotEmpty
|
final winRatePct = allTrades.isNotEmpty
|
||||||
? (allTrades.where((t) => t.pnlAbsolute >= 0).length / allTrades.length * 100)
|
? (allTrades.where((t) => t.pnlAbsolute >= 0).length / allTrades.length * 100)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
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 '../../favorites/cubit/favorites_cubit.dart';
|
||||||
|
import '../../favorites/models/favorite_asset_model.dart';
|
||||||
import '../models/trade_model.dart';
|
import '../models/trade_model.dart';
|
||||||
import 'trade_detail_modal.dart';
|
import 'trade_detail_modal.dart';
|
||||||
|
|
||||||
@@ -26,12 +29,23 @@ class TradeCard extends StatelessWidget {
|
|||||||
final isActive = trade.isActive;
|
final isActive = trade.isActive;
|
||||||
final isClosed = trade.isClosed;
|
final isClosed = trade.isClosed;
|
||||||
|
|
||||||
final pnlAbs = trade.calculatedPnlAbs;
|
return BlocBuilder<FavoritesCubit, FavoritesState>(
|
||||||
final pnlPct = trade.calculatedPnlPct;
|
builder: (context, favState) {
|
||||||
|
double livePrice = 0.0;
|
||||||
|
final keyUpper = (trade.isin.isNotEmpty ? trade.isin : trade.symbol).toUpperCase();
|
||||||
|
final match = favState.favoriteDetails.firstWhere(
|
||||||
|
(f) => f.isin.toUpperCase() == keyUpper || f.symbol.toUpperCase() == keyUpper,
|
||||||
|
orElse: () => const FavoriteAssetModel(isin: '', symbol: '', name: '', currentPrice: 0.0, change24h: 0.0),
|
||||||
|
);
|
||||||
|
if (match.currentPrice > 0) {
|
||||||
|
livePrice = match.currentPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pnlAbs = livePrice > 0 ? trade.calculateLivePnlAbs(livePrice) : trade.calculatedPnlAbs;
|
||||||
|
final pnlPct = livePrice > 0 ? trade.calculateLivePnlPct(livePrice) : trade.calculatedPnlPct;
|
||||||
final isPnlPos = pnlAbs >= 0;
|
final isPnlPos = pnlAbs >= 0;
|
||||||
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||||
|
final currPrice = livePrice > 0 ? livePrice : trade.effectiveCurrentPrice;
|
||||||
final currPrice = trade.effectiveCurrentPrice;
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => TradeDetailModal.show(
|
onTap: () => TradeDetailModal.show(
|
||||||
@@ -187,7 +201,7 @@ class TradeCard extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${trade.instrumentType.isNotEmpty ? trade.instrumentType : "Stock"} • ${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(0)}x Hebel" : ""}',
|
'${trade.instrumentType.isNotEmpty ? trade.instrumentType : "Stock"}${trade.derivativeIsin.isNotEmpty ? " (${trade.derivativeIsin})" : ""} • ${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(0)}x Hebel" : ""}',
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -249,6 +263,8 @@ class TradeCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _priceItem(String label, String val, Color valColor) {
|
Widget _priceItem(String label, String val, Color valColor) {
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ class TradeDetailModal extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_paramRow('Instrument Typ:', trade.instrumentType.isNotEmpty ? trade.instrumentType : 'Stock'),
|
_paramRow('Instrument Typ:', trade.instrumentType.isNotEmpty ? trade.instrumentType : 'Stock'),
|
||||||
|
if (trade.derivativeIsin.isNotEmpty) _paramRow('Derivat / Hebel ISIN:', trade.derivativeIsin),
|
||||||
_paramRow('Zeithorizont:', trade.timeframe.isNotEmpty ? trade.timeframe : '1D'),
|
_paramRow('Zeithorizont:', trade.timeframe.isNotEmpty ? trade.timeframe : '1D'),
|
||||||
if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(0)}x'),
|
if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(0)}x'),
|
||||||
if (trade.positionSize > 0) _paramRow('Positionsgröße:', '${trade.positionSize.toStringAsFixed(2)} €'),
|
if (trade.positionSize > 0) _paramRow('Positionsgröße:', '${trade.positionSize.toStringAsFixed(2)} €'),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:finlytic_app/core/theme/app_theme.dart';
|
import 'package:finlytic_app/core/theme/app_theme.dart';
|
||||||
import 'package:finlytic_app/core/widgets/status_badge.dart';
|
import 'package:finlytic_app/core/widgets/status_badge.dart';
|
||||||
|
import 'package:finlytic_app/core/network/api_client.dart';
|
||||||
import '../../../../features/trades/models/trade_model.dart';
|
import '../../../../features/trades/models/trade_model.dart';
|
||||||
import '../../../../features/trades/models/trade_acceptance_dto.dart';
|
import '../../../../features/trades/models/trade_acceptance_dto.dart';
|
||||||
|
|
||||||
@@ -8,6 +10,29 @@ class TradeExecutionDialog {
|
|||||||
static const double _defaultPositionSize = 1000.0;
|
static const double _defaultPositionSize = 1000.0;
|
||||||
static const double _defaultLeverage = 1.0;
|
static const double _defaultLeverage = 1.0;
|
||||||
|
|
||||||
|
static const List<String> _allowedInstruments = ['Stock', 'KnockOut', 'Option', 'CFD', 'Crypto'];
|
||||||
|
|
||||||
|
/// Normalisiert beliebige Freitexte/Bezeichnungen auf die erlaubten Dropdown-Werte
|
||||||
|
static String _normalizeInstrumentType(String raw) {
|
||||||
|
final clean = raw.toLowerCase().trim();
|
||||||
|
if (clean.contains('knock') || clean.contains('zertifikat') || clean.contains('turbo')) {
|
||||||
|
return 'KnockOut';
|
||||||
|
}
|
||||||
|
if (clean.contains('option')) {
|
||||||
|
return 'Option';
|
||||||
|
}
|
||||||
|
if (clean.contains('cfd')) {
|
||||||
|
return 'CFD';
|
||||||
|
}
|
||||||
|
if (clean.contains('crypto') || clean.contains('krypto')) {
|
||||||
|
return 'Crypto';
|
||||||
|
}
|
||||||
|
if (clean.contains('stock') || clean.contains('aktie') || clean.contains('etf')) {
|
||||||
|
return 'Stock';
|
||||||
|
}
|
||||||
|
return 'KnockOut'; // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required TradeModel trade,
|
required TradeModel trade,
|
||||||
@@ -19,10 +44,8 @@ class TradeExecutionDialog {
|
|||||||
final initEntry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : (trade.entryPrice > 0 ? trade.entryPrice : 100.0);
|
final initEntry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : (trade.entryPrice > 0 ? trade.entryPrice : 100.0);
|
||||||
final initPos = trade.positionSize > 0 ? trade.positionSize : _defaultPositionSize;
|
final initPos = trade.positionSize > 0 ? trade.positionSize : _defaultPositionSize;
|
||||||
final initLev = trade.leverageUsed > 0 ? trade.leverageUsed : _defaultLeverage;
|
final initLev = trade.leverageUsed > 0 ? trade.leverageUsed : _defaultLeverage;
|
||||||
final calcQty = (initEntry > 0 && initPos > 0) ? (initPos * initLev) / initEntry : 10.0;
|
|
||||||
|
|
||||||
// We don't have a direct quantity field in TradeModel, but we calculate it.
|
final calcQty = (initEntry > 0 && initPos > 0) ? (initPos / initEntry) : 10.0;
|
||||||
// Let's use the explicit quantity if it exists, otherwise calculate it
|
|
||||||
final initQty = trade.quantity > 0 ? trade.quantity : calcQty;
|
final initQty = trade.quantity > 0 ? trade.quantity : calcQty;
|
||||||
|
|
||||||
final actualEntryController = TextEditingController(text: initEntry.toStringAsFixed(2));
|
final actualEntryController = TextEditingController(text: initEntry.toStringAsFixed(2));
|
||||||
@@ -31,29 +54,142 @@ class TradeExecutionDialog {
|
|||||||
final quantityController = TextEditingController(text: initQty.toStringAsFixed(4));
|
final quantityController = TextEditingController(text: initQty.toStringAsFixed(4));
|
||||||
|
|
||||||
final entryFeeController = TextEditingController(text: trade.entryFee.toStringAsFixed(2));
|
final entryFeeController = TextEditingController(text: trade.entryFee.toStringAsFixed(2));
|
||||||
|
|
||||||
final exitFeeController = TextEditingController(text: trade.exitFee.toStringAsFixed(2));
|
final exitFeeController = TextEditingController(text: trade.exitFee.toStringAsFixed(2));
|
||||||
|
|
||||||
final slController = TextEditingController(text: trade.stopLoss.toString());
|
final slController = TextEditingController(text: trade.stopLoss.toString());
|
||||||
final tpController = TextEditingController(text: trade.takeProfit.toString());
|
final tpController = TextEditingController(text: trade.takeProfit.toString());
|
||||||
|
|
||||||
|
final derivativeIsinController = TextEditingController(text: trade.derivativeIsin);
|
||||||
|
|
||||||
|
// Normalisierte Zuweisung verhindert den DropdownButton Assertion-Error
|
||||||
|
String selectedInstrumentType = _normalizeInstrumentType(
|
||||||
|
trade.instrumentType.isNotEmpty ? trade.instrumentType : 'KnockOut',
|
||||||
|
);
|
||||||
|
|
||||||
|
bool isFetchingDerivativePrice = false;
|
||||||
|
|
||||||
void recalculateQuantity() {
|
void recalculateQuantity() {
|
||||||
final entry = double.tryParse(actualEntryController.text) ?? 0.0;
|
final entryStr = actualEntryController.text.replaceAll(',', '.').trim();
|
||||||
final posSize = double.tryParse(positionSizeController.text) ?? 0.0;
|
final posStr = positionSizeController.text.replaceAll(',', '.').trim();
|
||||||
final lev = double.tryParse(leverageController.text) ?? 1.0;
|
|
||||||
|
final entry = double.tryParse(entryStr) ?? 0.0;
|
||||||
|
final posSize = double.tryParse(posStr) ?? 0.0;
|
||||||
if (entry > 0 && posSize > 0) {
|
if (entry > 0 && posSize > 0) {
|
||||||
final q = (posSize * lev) / entry;
|
final q = posSize / entry;
|
||||||
quantityController.text = q.toStringAsFixed(4);
|
quantityController.text = q.toStringAsFixed(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TradeAcceptanceDto buildDto() {
|
||||||
|
final isinVal = trade.isin.isNotEmpty ? trade.isin : (trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol);
|
||||||
|
final symbolVal = trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol;
|
||||||
|
|
||||||
|
double parseNum(String text, double fallback) {
|
||||||
|
final clean = text.replaceAll(',', '.').trim();
|
||||||
|
return double.tryParse(clean) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TradeAcceptanceDto(
|
||||||
|
userId: trade.userId,
|
||||||
|
tradeId: trade.id,
|
||||||
|
analysisId: trade.analysisId,
|
||||||
|
isin: isinVal,
|
||||||
|
symbol: symbolVal,
|
||||||
|
actualEntryPrice: parseNum(actualEntryController.text, trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice),
|
||||||
|
positionSize: parseNum(positionSizeController.text, trade.positionSize > 0 ? trade.positionSize : 1000.0),
|
||||||
|
leverageUsed: parseNum(leverageController.text, trade.leverageUsed > 0 ? trade.leverageUsed : 1.0),
|
||||||
|
entryFee: parseNum(entryFeeController.text, trade.entryFee),
|
||||||
|
exitFee: parseNum(exitFeeController.text, trade.exitFee),
|
||||||
|
quantity: parseNum(quantityController.text, trade.quantity),
|
||||||
|
executionTimestamp: DateTime.now().toUtc(),
|
||||||
|
signalType: trade.signalType,
|
||||||
|
entryPrice: trade.entryPrice,
|
||||||
|
stopLoss: parseNum(slController.text, trade.stopLoss),
|
||||||
|
takeProfit: parseNum(tpController.text, trade.takeProfit),
|
||||||
|
instrumentType: selectedInstrumentType,
|
||||||
|
derivativeIsin: derivativeIsinController.text.trim(),
|
||||||
|
timeframe: trade.timeframe,
|
||||||
|
reasoning: trade.reasoning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchDerivativePrice(StateSetter setModalState, String inputIsin) async {
|
||||||
|
final cleanIsin = inputIsin.trim().toUpperCase();
|
||||||
|
if (cleanIsin.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Bitte gib eine gültige Derivat/Knock-Out ISIN ein.'),
|
||||||
|
backgroundColor: Colors.amber,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalState(() => isFetchingDerivativePrice = true);
|
||||||
|
try {
|
||||||
|
final apiClient = context.read<ApiClient>();
|
||||||
|
final res = await apiClient.get('/api/v1/assets/$cleanIsin/technicals?forceRefresh=true');
|
||||||
|
if (res.statusCode == 200 && res.data != null) {
|
||||||
|
final Map<String, dynamic> data = res.data;
|
||||||
|
double? fetchedPrice;
|
||||||
|
if (data['candles'] is List && (data['candles'] as List).isNotEmpty) {
|
||||||
|
fetchedPrice = ((data['candles'] as List).last['close'] as num?)?.toDouble();
|
||||||
|
} else if (data['currentPrice'] != null) {
|
||||||
|
fetchedPrice = (data['currentPrice'] as num?)?.toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchedPrice != null && fetchedPrice > 0) {
|
||||||
|
actualEntryController.text = fetchedPrice.toStringAsFixed(2);
|
||||||
|
recalculateQuantity();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Live-Kurs für Derivat $cleanIsin abgerufen: €${fetchedPrice.toStringAsFixed(2)}'),
|
||||||
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Kein Kurs für Derivat ISIN $cleanIsin gefunden.'),
|
||||||
|
backgroundColor: Colors.amber,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Fehler beim Abrufen des Kurses für $cleanIsin via tr_GetPrice: $e'),
|
||||||
|
backgroundColor: AppTheme.accentRed,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setModalState(() => isFetchingDerivativePrice = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actualEntryController.addListener(recalculateQuantity);
|
actualEntryController.addListener(recalculateQuantity);
|
||||||
positionSizeController.addListener(recalculateQuantity);
|
positionSizeController.addListener(recalculateQuantity);
|
||||||
leverageController.addListener(recalculateQuantity);
|
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (stfContext, setModalState) {
|
||||||
|
final isKnockout = selectedInstrumentType.toLowerCase().contains('knock') ||
|
||||||
|
selectedInstrumentType.toLowerCase().contains('zertifikat') ||
|
||||||
|
selectedInstrumentType.toLowerCase().contains('option') ||
|
||||||
|
selectedInstrumentType.toLowerCase().contains('cfd');
|
||||||
|
|
||||||
|
// Absicherung gegen Assertion-Errors: Stellt sicher, dass der selektierte Wert in der Liste existiert
|
||||||
|
final safeInstrumentValue = _allowedInstruments.contains(selectedInstrumentType)
|
||||||
|
? selectedInstrumentType
|
||||||
|
: 'KnockOut';
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: AppTheme.cardSurface,
|
backgroundColor: AppTheme.cardSurface,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -65,7 +201,10 @@ class TradeExecutionDialog {
|
|||||||
Icon(isActive ? Icons.tune : Icons.edit_note_outlined, color: AppTheme.primaryEmerald, size: 22),
|
Icon(isActive ? Icons.tune : Icons.edit_note_outlined, color: AppTheme.primaryEmerald, size: 22),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(isActive ? 'Einstellungen für Trade #${trade.id}' : 'Trade-Ausführung & Parameter', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
child: Text(
|
||||||
|
isActive ? 'Einstellungen für Trade #${trade.id}' : 'Trade-Ausführung & Parameter',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -76,7 +215,10 @@ class TradeExecutionDialog {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
Text(
|
||||||
|
'Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}',
|
||||||
|
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
Builder(
|
Builder(
|
||||||
@@ -100,11 +242,11 @@ class TradeExecutionDialog {
|
|||||||
final riskWarning = trade.riskWarning;
|
final riskWarning = trade.riskWarning;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: signalColor.withValues(alpha: 0.12),
|
color: AppTheme.glassSurface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: signalColor, width: 1.5),
|
border: Border.all(color: AppTheme.glassBorder),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -123,17 +265,16 @@ class TradeExecutionDialog {
|
|||||||
color: AppTheme.glassSurface,
|
color: AppTheme.glassSurface,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(trade.instrumentType.toString(), style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
|
child: Text(trade.instrumentType, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (trade.winRate > 0)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (trade.winRate > 0) ...[
|
|
||||||
Icon(Icons.bolt, size: 14, color: AppTheme.accentCyan),
|
Icon(Icons.bolt, size: 14, color: AppTheme.accentCyan),
|
||||||
Text('Win-Rate: ${trade.winRate}%', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
|
Text('Win-Rate: ${trade.winRate}%', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -145,7 +286,7 @@ class TradeExecutionDialog {
|
|||||||
Text('Haltedauer: ${trade.timeframe.isNotEmpty ? trade.timeframe : '1-14 Tage'}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
Text('Haltedauer: ${trade.timeframe.isNotEmpty ? trade.timeframe : '1-14 Tage'}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
Text('Risiko: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
Text('Risiko: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
if (trade.vixValue > 0)
|
if (trade.vixValue > 0)
|
||||||
Text('VIX: ${_fmt(trade.vixValue)} (${trade.vixRegime})', style: TextStyle(color: Colors.orangeAccent, fontSize: 11, fontWeight: FontWeight.bold)),
|
Text('VIX: ${_fmt(trade.vixValue)} (${trade.vixRegime})', style: const TextStyle(color: Colors.orangeAccent, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(color: Colors.white12, height: 16),
|
const Divider(color: Colors.white12, height: 16),
|
||||||
@@ -202,6 +343,65 @@ class TradeExecutionDialog {
|
|||||||
const Text('Ihre Ausführungsdaten für das Depot:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
|
const Text('Ihre Ausführungsdaten für das Depot:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Instrument-Type Dropdown mit abgesichertem Value
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: safeInstrumentValue,
|
||||||
|
dropdownColor: AppTheme.cardSurface,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Finanzinstrument Typ',
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'Stock', child: Text('Aktie / ETF (Direktinvestment)', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||||
|
DropdownMenuItem(value: 'KnockOut', child: Text('Knock-Out Zertifikat', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||||
|
DropdownMenuItem(value: 'Option', child: Text('Optionsschein / Derivat', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||||
|
DropdownMenuItem(value: 'CFD', child: Text('CFD (Hebel-Derivat)', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||||
|
DropdownMenuItem(value: 'Crypto', child: Text('Krypto', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) {
|
||||||
|
setModalState(() {
|
||||||
|
selectedInstrumentType = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
if (isKnockout) ...[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: derivativeIsinController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Knock-Out / Derivat ISIN (z.B. DE000...)',
|
||||||
|
hintText: 'ISIN des Hebels eingeben...',
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: isFetchingDerivativePrice
|
||||||
|
? null
|
||||||
|
: () => fetchDerivativePrice(setModalState, derivativeIsinController.text),
|
||||||
|
icon: isFetchingDerivativePrice
|
||||||
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||||
|
: const Icon(Icons.bolt, size: 16),
|
||||||
|
label: const Text('tr_GetPrice'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.accentCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -237,7 +437,7 @@ class TradeExecutionDialog {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: quantityController,
|
controller: quantityController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
decoration: const InputDecoration(labelText: 'Stückzahl (Autom. berechnet)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
decoration: const InputDecoration(labelText: 'Stückzahl (Invest. / Einstieg)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -293,42 +493,7 @@ class TradeExecutionDialog {
|
|||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
|
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
|
||||||
),
|
),
|
||||||
if (isActive)
|
if (!isActive && onReject != null)
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
final dto = TradeAcceptanceDto(
|
|
||||||
userId: trade.userId,
|
|
||||||
tradeId: trade.id,
|
|
||||||
analysisId: trade.analysisId,
|
|
||||||
isin: trade.isin,
|
|
||||||
symbol: trade.symbol,
|
|
||||||
actualEntryPrice: double.tryParse(actualEntryController.text) ?? trade.entryPrice,
|
|
||||||
positionSize: double.tryParse(positionSizeController.text) ?? 1000.0,
|
|
||||||
leverageUsed: double.tryParse(leverageController.text) ?? 1.0,
|
|
||||||
entryFee: double.tryParse(entryFeeController.text) ?? 0.0,
|
|
||||||
exitFee: double.tryParse(exitFeeController.text) ?? 0.0,
|
|
||||||
quantity: double.tryParse(quantityController.text) ?? 0.0,
|
|
||||||
executionTimestamp: DateTime.now().toUtc(),
|
|
||||||
signalType: trade.signalType,
|
|
||||||
entryPrice: trade.entryPrice,
|
|
||||||
stopLoss: double.tryParse(slController.text) ?? trade.stopLoss,
|
|
||||||
takeProfit: double.tryParse(tpController.text) ?? trade.takeProfit,
|
|
||||||
instrumentType: trade.instrumentType,
|
|
||||||
timeframe: trade.timeframe,
|
|
||||||
reasoning: trade.reasoning,
|
|
||||||
);
|
|
||||||
onAccept(dto);
|
|
||||||
Navigator.pop(dialogContext);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.save, size: 16),
|
|
||||||
label: const Text('Speichern'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else ...[
|
|
||||||
if (onReject != null)
|
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
onReject(trade.id);
|
onReject(trade.id);
|
||||||
@@ -340,49 +505,28 @@ class TradeExecutionDialog {
|
|||||||
side: BorderSide(color: AppTheme.accentRed),
|
side: BorderSide(color: AppTheme.accentRed),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
if (!isActive && onReject != null) const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final dto = TradeAcceptanceDto(
|
final dto = buildDto();
|
||||||
userId: trade.userId,
|
|
||||||
tradeId: trade.id,
|
|
||||||
analysisId: trade.analysisId,
|
|
||||||
isin: trade.isin,
|
|
||||||
symbol: trade.symbol,
|
|
||||||
actualEntryPrice: double.tryParse(actualEntryController.text) ?? trade.entryPrice,
|
|
||||||
positionSize: double.tryParse(positionSizeController.text) ?? 1000.0,
|
|
||||||
leverageUsed: double.tryParse(leverageController.text) ?? 1.0,
|
|
||||||
entryFee: double.tryParse(entryFeeController.text) ?? 0.0,
|
|
||||||
exitFee: double.tryParse(exitFeeController.text) ?? 0.0,
|
|
||||||
quantity: double.tryParse(quantityController.text) ?? 0.0,
|
|
||||||
executionTimestamp: DateTime.now().toUtc(),
|
|
||||||
signalType: trade.signalType,
|
|
||||||
entryPrice: trade.entryPrice,
|
|
||||||
stopLoss: double.tryParse(slController.text) ?? trade.stopLoss,
|
|
||||||
takeProfit: double.tryParse(tpController.text) ?? trade.takeProfit,
|
|
||||||
instrumentType: trade.instrumentType,
|
|
||||||
timeframe: trade.timeframe,
|
|
||||||
reasoning: trade.reasoning,
|
|
||||||
);
|
|
||||||
onAccept(dto);
|
onAccept(dto);
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.check_circle, size: 16),
|
icon: Icon(isActive ? Icons.save : Icons.check_circle, size: 16),
|
||||||
label: const Text('Trade Annehmen & Ausführen'),
|
label: Text(isActive ? 'Einstellungen Speichern' : 'Trade Annehmen & Ausführen'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
backgroundColor: isActive ? AppTheme.accentCyan : AppTheme.primaryEmerald,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static String _fmt(dynamic val) {
|
static String _fmt(dynamic val) {
|
||||||
if (val == null) return '0.00';
|
if (val == null) return '0.00';
|
||||||
if (val is double) {
|
if (val is double) {
|
||||||
@@ -396,7 +540,7 @@ class TradeExecutionDialog {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: TextStyle(color: Colors.white54, fontSize: 11)),
|
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)),
|
Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user