feat(App): update Finlytic Flutter app UI and blocs

This commit is contained in:
2026-08-09 21:01:46 +02:00
parent e7427b7464
commit a708d2977c
591 changed files with 1095105 additions and 0 deletions
@@ -0,0 +1,40 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/asset_detail/repositories/asset_repository.dart';
import 'asset_detail_event.dart';
import 'asset_detail_state.dart';
class AssetDetailBloc extends Bloc<AssetDetailEvent, AssetDetailState> {
final AssetRepository repository;
AssetDetailBloc({required this.repository}) : super(AssetDetailInitial()) {
on<LoadAssetData>(_onLoadAssetData);
on<ForceRefreshAssetData>(_onForceRefreshAssetData);
}
Future<void> _onLoadAssetData(LoadAssetData event, Emitter<AssetDetailState> emit) async {
emit(AssetDetailLoading());
try {
final results = await Future.wait([
repository.getFundamentalData(event.symbol),
repository.getTechnicalAnalysis(event.symbol),
]);
emit(AssetDetailLoaded(
fundamentalData: results[0] as dynamic,
technicalAnalysis: results[1] as dynamic,
));
} catch (e) {
emit(AssetDetailError("Fehler beim Laden der Asset-Daten."));
}
}
Future<void> _onForceRefreshAssetData(ForceRefreshAssetData event, Emitter<AssetDetailState> emit) async {
try {
await repository.forceRefreshFundamentalData(event.symbol);
// Optional: re-load after a delay, or rely on MQTT/SignalR to push the new data.
} catch (e) {
// ignore
}
}
}
@@ -0,0 +1,26 @@
import 'package:equatable/equatable.dart';
abstract class AssetDetailEvent extends Equatable {
const AssetDetailEvent();
@override
List<Object?> get props => [];
}
class LoadAssetData extends AssetDetailEvent {
final String symbol;
const LoadAssetData(this.symbol);
@override
List<Object?> get props => [symbol];
}
class ForceRefreshAssetData extends AssetDetailEvent {
final String symbol;
const ForceRefreshAssetData(this.symbol);
@override
List<Object?> get props => [symbol];
}
@@ -0,0 +1,33 @@
import 'package:equatable/equatable.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';
abstract class AssetDetailState extends Equatable {
const AssetDetailState();
@override
List<Object?> get props => [];
}
class AssetDetailInitial extends AssetDetailState {}
class AssetDetailLoading extends AssetDetailState {}
class AssetDetailLoaded extends AssetDetailState {
final FundamentalDataModel? fundamentalData;
final TechnicalAnalysisModel? technicalAnalysis;
const AssetDetailLoaded({this.fundamentalData, this.technicalAnalysis});
@override
List<Object?> get props => [fundamentalData, technicalAnalysis];
}
class AssetDetailError extends AssetDetailState {
final String message;
const AssetDetailError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,19 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_fundamentals_event.dart';
import 'asset_fundamentals_state.dart';
import '../../repositories/asset_repository.dart';
class AssetFundamentalsBloc extends Bloc<AssetFundamentalsEvent, AssetFundamentalsState> {
final AssetRepository repository;
AssetFundamentalsBloc({required this.repository}) : super(AssetFundamentalsInitial()) {
on<LoadAssetFundamentals>((event, emit) async {
emit(AssetFundamentalsLoading());
try {
final data = await repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker);
emit(AssetFundamentalsLoaded(data));
} catch (e) {
emit(AssetFundamentalsError(e.toString()));
}
});
}
}
@@ -0,0 +1,7 @@
abstract class AssetFundamentalsEvent {}
class LoadAssetFundamentals extends AssetFundamentalsEvent {
final String isin;
final bool forceRefresh;
final String? ticker;
LoadAssetFundamentals(this.isin, {this.forceRefresh = false, this.ticker});
}
@@ -0,0 +1,13 @@
import '../../models/fundamental_data_model.dart';
abstract class AssetFundamentalsState {}
class AssetFundamentalsInitial extends AssetFundamentalsState {}
class AssetFundamentalsLoading extends AssetFundamentalsState {}
class AssetFundamentalsLoaded extends AssetFundamentalsState {
final FundamentalDataModel? data;
AssetFundamentalsLoaded(this.data);
}
class AssetFundamentalsError extends AssetFundamentalsState {
final String message;
AssetFundamentalsError(this.message);
}
@@ -0,0 +1,20 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_header_event.dart';
import 'asset_header_state.dart';
import '../../repositories/asset_repository.dart';
class AssetHeaderBloc extends Bloc<AssetHeaderEvent, AssetHeaderState> {
final AssetRepository repository;
AssetHeaderBloc({required this.repository}) : super(AssetHeaderInitial()) {
on<LoadAssetHeader>((event, emit) async {
final prevData = state is AssetHeaderLoaded ? (state as AssetHeaderLoaded).data : (state is AssetHeaderLoading ? (state as AssetHeaderLoading).previousData : null);
emit(AssetHeaderLoading(previousData: prevData));
try {
final data = await repository.getAssetHeader(event.isin, exchange: event.exchange, ticker: event.ticker);
emit(AssetHeaderLoaded(data));
} catch (e) {
emit(AssetHeaderError(e.toString()));
}
});
}
}
@@ -0,0 +1,8 @@
abstract class AssetHeaderEvent {}
class LoadAssetHeader extends AssetHeaderEvent {
final String isin;
final bool forceRefresh;
final String? exchange;
final String? ticker;
LoadAssetHeader(this.isin, {this.forceRefresh = false, this.exchange, this.ticker});
}
@@ -0,0 +1,16 @@
import '../../models/asset_model.dart';
abstract class AssetHeaderState {}
class AssetHeaderInitial extends AssetHeaderState {}
class AssetHeaderLoading extends AssetHeaderState {
final AssetModel? previousData;
AssetHeaderLoading({this.previousData});
}
class AssetHeaderLoaded extends AssetHeaderState {
final AssetModel? data;
AssetHeaderLoaded(this.data);
}
class AssetHeaderError extends AssetHeaderState {
final String message;
AssetHeaderError(this.message);
}
@@ -0,0 +1,19 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_technical_event.dart';
import 'asset_technical_state.dart';
import '../../repositories/asset_repository.dart';
class AssetTechnicalBloc extends Bloc<AssetTechnicalEvent, AssetTechnicalState> {
final AssetRepository repository;
AssetTechnicalBloc({required this.repository}) : super(AssetTechnicalInitial()) {
on<LoadAssetTechnical>((event, emit) async {
emit(AssetTechnicalLoading());
try {
final data = await repository.getAssetTechnical(event.isin, event.forceRefresh, ticker: event.ticker);
emit(AssetTechnicalLoaded(data));
} catch (e) {
emit(AssetTechnicalError(e.toString()));
}
});
}
}
@@ -0,0 +1,7 @@
abstract class AssetTechnicalEvent {}
class LoadAssetTechnical extends AssetTechnicalEvent {
final String isin;
final bool forceRefresh;
final String? ticker;
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.ticker});
}
@@ -0,0 +1,13 @@
import '../../models/technical_analysis_model.dart';
abstract class AssetTechnicalState {}
class AssetTechnicalInitial extends AssetTechnicalState {}
class AssetTechnicalLoading extends AssetTechnicalState {}
class AssetTechnicalLoaded extends AssetTechnicalState {
final TechnicalAnalysisModel? data;
AssetTechnicalLoaded(this.data);
}
class AssetTechnicalError extends AssetTechnicalState {
final String message;
AssetTechnicalError(this.message);
}
@@ -0,0 +1,52 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_trades_event.dart';
import 'asset_trades_state.dart';
import '../../repositories/asset_repository.dart';
class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
final AssetRepository repository;
AssetTradesBloc({required this.repository}) : super(AssetTradesInitial()) {
on<LoadAssetTrades>((event, emit) async {
emit(AssetTradesLoading());
try {
final data = await repository.getAssetTrades(event.isin, event.status);
emit(AssetTradesLoaded(data));
} catch (e) {
emit(AssetTradesError(e.toString()));
}
});
on<TriggerManualAnalysis>((event, emit) async {
try {
await repository.triggerManualAnalysis(event.isin, payload: event.payload);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
}
});
on<RejectTradeEvent>((event, emit) async {
try {
await repository.rejectTrade(event.tradeId);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to reject trade: $e"));
}
});
on<AcceptTradeEvent>((event, emit) async {
try {
await repository.acceptTrade(event.tradeAcceptanceDto);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to accept trade: $e"));
}
});
on<CloseTradeEvent>((event, emit) async {
try {
await repository.closeTrade(event.tradeId, event.exitPrice);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to close trade: $e"));
}
});
}
}
@@ -0,0 +1,31 @@
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
abstract class AssetTradesEvent {}
class LoadAssetTrades extends AssetTradesEvent {
final String isin;
final String? status;
LoadAssetTrades(this.isin, {this.status});
}
class TriggerManualAnalysis extends AssetTradesEvent {
final String isin;
final ManualAnalysisRequestDto? payload;
TriggerManualAnalysis(this.isin, {this.payload});
}
class RejectTradeEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
RejectTradeEvent(this.tradeId, this.isin);
}
class AcceptTradeEvent extends AssetTradesEvent {
final TradeAcceptanceDto tradeAcceptanceDto;
final String isin;
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
}
class CloseTradeEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
final double exitPrice;
CloseTradeEvent(this.tradeId, this.isin, this.exitPrice);
}
@@ -0,0 +1,13 @@
import '../../../trades/models/trade_model.dart';
abstract class AssetTradesState {}
class AssetTradesInitial extends AssetTradesState {}
class AssetTradesLoading extends AssetTradesState {}
class AssetTradesLoaded extends AssetTradesState {
final List<TradeModel> data;
AssetTradesLoaded(this.data);
}
class AssetTradesError extends AssetTradesState {
final String message;
AssetTradesError(this.message);
}
@@ -0,0 +1,106 @@
import 'package:equatable/equatable.dart';
class AssetModel extends Equatable {
final String isin;
final String symbol;
final String name;
final double currentPrice;
final String currency;
final String exchange;
final List<String> exchanges;
final List<AssetTickerOption> tickers;
final String image;
const AssetModel({
required this.isin,
required this.symbol,
required this.name,
this.currentPrice = 0.0,
required this.currency,
required this.exchange,
required this.exchanges,
required this.tickers,
required this.image,
});
factory AssetModel.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
return AssetModel(
isin: json['isin']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? '',
name: json['name']?.toString() ?? '',
currentPrice: parseDouble(json['price'] ?? json['currentPrice']),
currency: json['currency']?.toString() ?? 'EUR',
exchange: json['exchange']?.toString() ?? 'XETRA',
exchanges: (json['exchanges'] as List?)?.map((e) => e.toString()).toList() ?? [],
tickers: (json['tickers'] as List?)
?.map((t) => AssetTickerOption.fromJson(t))
.toList() ??
[],
image: json['image']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'symbol': symbol,
'name': name,
'currentPrice': currentPrice,
'currency': currency,
'exchange': exchange,
'exchanges': exchanges,
'tickers': tickers.map((t) => t.toJson()).toList(),
'image': image,
};
}
@override
List<Object?> get props => [isin, symbol, name, currentPrice, currency, exchange, exchanges, tickers, image];
}
class AssetTickerOption extends Equatable {
final String ticker;
final String exchange;
final String tradingCurrency;
final double currentPrice;
const AssetTickerOption({
required this.ticker,
required this.exchange,
required this.tradingCurrency,
required this.currentPrice,
});
factory AssetTickerOption.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
return AssetTickerOption(
ticker: json['ticker']?.toString() ?? '',
exchange: json['exchange']?.toString() ?? 'XETRA',
tradingCurrency: json['tradingCurrency']?.toString() ?? json['currency']?.toString() ?? 'EUR',
currentPrice: parseDouble(json['currentPrice'] ?? json['price']),
);
}
Map<String, dynamic> toJson() {
return {
'ticker': ticker,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'currentPrice': currentPrice,
};
}
@override
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
}
@@ -0,0 +1,528 @@
import 'package:equatable/equatable.dart';
class FundamentalDataModel extends Equatable {
final String isin;
final String primaryTicker;
final String ticker;
final String companyName;
final String? exchange;
final String? tradingCurrency;
final String? businessSummary;
final String? sector;
final String? industry;
final String? country;
final int? employees;
final double currentPrice;
final double dayChangeAbsolute;
final double dayChangePercent;
final double fiftyTwoWeekHigh;
final double fiftyTwoWeekLow;
final double marketCapitalization;
final double enterpriseValue;
final double? peRatioTrailing;
final double? peRatioForward;
final double? pegRatio;
final double? pbRatio;
final double? psRatio;
final double? evToEbitda;
final double? evToRevenue;
final double? grossMargin;
final double? operatingMargin;
final double? netProfitMargin;
final double? returnOnEquity;
final double? returnOnAssets;
final double? returnOnInvestedCapital;
final double? debtToEquity;
final double? currentRatio;
final double? quickRatio;
final double? interestCoverage;
final double? dividendYield;
final double? payoutRatio;
final String? exDividendDate;
final String? nextEarningsDate;
final double? percentHeldByInstitutions;
final double? percentHeldByInsiders;
final double? shortRatio;
final double? shortPercentOfFloat;
final String? consensusRating;
final double? priceTargetLow;
final double? priceTargetHigh;
final double? priceTargetMedian;
final double? priceTargetMean;
final List<CompanyExecutiveModel> executives;
final List<FinancialStatementModel> financialStatements;
final List<ForwardEstimateModel> estimates;
const FundamentalDataModel({
required this.isin,
required this.primaryTicker,
required this.ticker,
required this.companyName,
this.exchange,
this.tradingCurrency,
this.businessSummary,
this.sector,
this.industry,
this.country,
this.employees,
required this.currentPrice,
required this.dayChangeAbsolute,
required this.dayChangePercent,
required this.fiftyTwoWeekHigh,
required this.fiftyTwoWeekLow,
required this.marketCapitalization,
required this.enterpriseValue,
this.peRatioTrailing,
this.peRatioForward,
this.pegRatio,
this.pbRatio,
this.psRatio,
this.evToEbitda,
this.evToRevenue,
this.grossMargin,
this.operatingMargin,
this.netProfitMargin,
this.returnOnEquity,
this.returnOnAssets,
this.returnOnInvestedCapital,
this.debtToEquity,
this.currentRatio,
this.quickRatio,
this.interestCoverage,
this.dividendYield,
this.payoutRatio,
this.exDividendDate,
this.nextEarningsDate,
this.percentHeldByInstitutions,
this.percentHeldByInsiders,
this.shortRatio,
this.shortPercentOfFloat,
this.consensusRating,
this.priceTargetLow,
this.priceTargetHigh,
this.priceTargetMedian,
this.priceTargetMean,
required this.executives,
required this.financialStatements,
required this.estimates,
});
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
double? parseNullableDouble(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FundamentalDataModel(
isin: json['isin']?.toString() ?? '',
primaryTicker: json['primaryTicker']?.toString() ?? '',
ticker: json['ticker']?.toString() ?? '',
companyName: json['companyName']?.toString() ?? '',
exchange: json['exchange']?.toString(),
tradingCurrency: json['tradingCurrency']?.toString(),
businessSummary: json['businessSummary']?.toString(),
sector: json['sector']?.toString(),
industry: json['industry']?.toString(),
country: json['country']?.toString(),
employees: json['employees'] != null ? int.tryParse(json['employees'].toString()) : null,
currentPrice: parseDouble(json['currentPrice']),
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
dayChangePercent: parseDouble(json['dayChangePercent']),
fiftyTwoWeekHigh: parseDouble(json['fiftyTwoWeekHigh']),
fiftyTwoWeekLow: parseDouble(json['fiftyTwoWeekLow']),
marketCapitalization: parseDouble(json['marketCapitalization'] ?? json['marketCap']),
enterpriseValue: parseDouble(json['enterpriseValue']),
peRatioTrailing: parseNullableDouble(json['peRatioTrailing'] ?? json['peRatio']),
peRatioForward: parseNullableDouble(json['peRatioForward']),
pegRatio: parseNullableDouble(json['pegRatio']),
pbRatio: parseNullableDouble(json['pbRatio']),
psRatio: parseNullableDouble(json['psRatio']),
evToEbitda: parseNullableDouble(json['evToEbitda']),
evToRevenue: parseNullableDouble(json['evToRevenue']),
grossMargin: parseNullableDouble(json['grossMargin']),
operatingMargin: parseNullableDouble(json['operatingMargin']),
netProfitMargin: parseNullableDouble(json['netProfitMargin']),
returnOnEquity: parseNullableDouble(json['returnOnEquity']),
returnOnAssets: parseNullableDouble(json['returnOnAssets']),
returnOnInvestedCapital: parseNullableDouble(json['returnOnInvestedCapital']),
debtToEquity: parseNullableDouble(json['debtToEquity']),
currentRatio: parseNullableDouble(json['currentRatio']),
quickRatio: parseNullableDouble(json['quickRatio']),
interestCoverage: parseNullableDouble(json['interestCoverage']),
dividendYield: parseNullableDouble(json['dividendYield']),
payoutRatio: parseNullableDouble(json['payoutRatio']),
exDividendDate: json['exDividendDate']?.toString(),
nextEarningsDate: json['nextEarningsDate']?.toString(),
percentHeldByInstitutions: parseNullableDouble(json['percentHeldByInstitutions']),
percentHeldByInsiders: parseNullableDouble(json['percentHeldByInsiders']),
shortRatio: parseNullableDouble(json['shortRatio']),
shortPercentOfFloat: parseNullableDouble(json['shortPercentOfFloat']),
consensusRating: json['consensusRating']?.toString(),
priceTargetLow: parseNullableDouble(json['priceTargetLow']),
priceTargetHigh: parseNullableDouble(json['priceTargetHigh']),
priceTargetMedian: parseNullableDouble(json['priceTargetMedian']),
priceTargetMean: parseNullableDouble(json['priceTargetMean']),
executives: (json['executives'] as List?)
?.map((e) => CompanyExecutiveModel.fromJson(e))
.toList() ??
[],
financialStatements: (json['financialStatements'] as List?)
?.map((e) => FinancialStatementModel.fromJson(e))
.toList() ??
[],
estimates: (json['estimates'] as List?)
?.map((e) => ForwardEstimateModel.fromJson(e))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'primaryTicker': primaryTicker,
'ticker': ticker,
'companyName': companyName,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'businessSummary': businessSummary,
'sector': sector,
'industry': industry,
'country': country,
'employees': employees,
'currentPrice': currentPrice,
'dayChangeAbsolute': dayChangeAbsolute,
'dayChangePercent': dayChangePercent,
'fiftyTwoWeekHigh': fiftyTwoWeekHigh,
'fiftyTwoWeekLow': fiftyTwoWeekLow,
'marketCapitalization': marketCapitalization,
'enterpriseValue': enterpriseValue,
'peRatioTrailing': peRatioTrailing,
'peRatioForward': peRatioForward,
'pegRatio': pegRatio,
'pbRatio': pbRatio,
'psRatio': psRatio,
'evToEbitda': evToEbitda,
'evToRevenue': evToRevenue,
'grossMargin': grossMargin,
'operatingMargin': operatingMargin,
'netProfitMargin': netProfitMargin,
'returnOnEquity': returnOnEquity,
'returnOnAssets': returnOnAssets,
'returnOnInvestedCapital': returnOnInvestedCapital,
'debtToEquity': debtToEquity,
'currentRatio': currentRatio,
'quickRatio': quickRatio,
'dividendYield': dividendYield,
'payoutRatio': payoutRatio,
'exDividendDate': exDividendDate,
'nextEarningsDate': nextEarningsDate,
'percentHeldByInstitutions': percentHeldByInstitutions,
'percentHeldByInsiders': percentHeldByInsiders,
'shortRatio': shortRatio,
'shortPercentOfFloat': shortPercentOfFloat,
'consensusRating': consensusRating,
'priceTargetLow': priceTargetLow,
'priceTargetHigh': priceTargetHigh,
'priceTargetMedian': priceTargetMedian,
'priceTargetMean': priceTargetMean,
'executives': executives.map((e) => e.toJson()).toList(),
'financialStatements': financialStatements.map((e) => e.toJson()).toList(),
'estimates': estimates.map((e) => e.toJson()).toList(),
};
}
@override
List<Object?> get props => [
isin,
primaryTicker,
ticker,
companyName,
exchange,
tradingCurrency,
businessSummary,
sector,
industry,
country,
employees,
currentPrice,
dayChangeAbsolute,
dayChangePercent,
fiftyTwoWeekHigh,
fiftyTwoWeekLow,
marketCapitalization,
enterpriseValue,
peRatioTrailing,
peRatioForward,
pegRatio,
pbRatio,
psRatio,
evToEbitda,
evToRevenue,
grossMargin,
operatingMargin,
netProfitMargin,
returnOnEquity,
returnOnAssets,
returnOnInvestedCapital,
debtToEquity,
currentRatio,
quickRatio,
dividendYield,
payoutRatio,
exDividendDate,
nextEarningsDate,
percentHeldByInstitutions,
percentHeldByInsiders,
shortRatio,
shortPercentOfFloat,
consensusRating,
priceTargetLow,
priceTargetHigh,
priceTargetMedian,
priceTargetMean,
executives,
financialStatements,
estimates,
];
}
class CompanyExecutiveModel extends Equatable {
final String name;
final String title;
final int? age;
final double? compensation;
const CompanyExecutiveModel({
required this.name,
required this.title,
this.age,
this.compensation,
});
factory CompanyExecutiveModel.fromJson(Map<String, dynamic> json) {
return CompanyExecutiveModel(
name: json['name']?.toString() ?? '',
title: json['title']?.toString() ?? '',
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
compensation: json['compensation'] != null ? double.tryParse(json['compensation'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'title': title,
'age': age,
'compensation': compensation,
};
}
@override
List<Object?> get props => [name, title, age, compensation];
}
class FinancialStatementModel extends Equatable {
final String periodType;
final String endDate;
// Income Statement
final double? totalRevenue;
final double? costOfRevenue;
final double? grossProfit;
final double? operatingExpenses;
final double? operatingIncome;
final double? ebitda;
final double? netIncome;
final double? epsBasic;
final double? epsDiluted;
// Balance Sheet
final double? cashAndCashEquivalents;
final double? accountsReceivable;
final double? inventory;
final double? totalCurrentAssets;
final double? totalNonCurrentAssets;
final double? currentLiabilities;
final double? longTermDebt;
final double? totalLiabilities;
final double? totalStockholdersEquity;
// Cash Flow
final double? operatingCashFlow;
final double? investingCashFlow;
final double? capitalExpenditures;
final double? financingCashFlow;
final double? freeCashFlow;
const FinancialStatementModel({
required this.periodType,
required this.endDate,
this.totalRevenue,
this.costOfRevenue,
this.grossProfit,
this.operatingExpenses,
this.operatingIncome,
this.ebitda,
this.netIncome,
this.epsBasic,
this.epsDiluted,
this.cashAndCashEquivalents,
this.accountsReceivable,
this.inventory,
this.totalCurrentAssets,
this.totalNonCurrentAssets,
this.currentLiabilities,
this.longTermDebt,
this.totalLiabilities,
this.totalStockholdersEquity,
this.operatingCashFlow,
this.investingCashFlow,
this.capitalExpenditures,
this.financingCashFlow,
this.freeCashFlow,
});
factory FinancialStatementModel.fromJson(Map<String, dynamic> json) {
double? parseD(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FinancialStatementModel(
periodType: json['periodType']?.toString() ?? '',
endDate: json['endDate']?.toString() ?? '',
totalRevenue: parseD(json['totalRevenue']),
costOfRevenue: parseD(json['costOfRevenue']),
grossProfit: parseD(json['grossProfit']),
operatingExpenses: parseD(json['operatingExpenses']),
operatingIncome: parseD(json['operatingIncome']),
ebitda: parseD(json['ebitda']),
netIncome: parseD(json['netIncome']),
epsBasic: parseD(json['epsBasic']),
epsDiluted: parseD(json['epsDiluted']),
cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']),
accountsReceivable: parseD(json['accountsReceivable']),
inventory: parseD(json['inventory']),
totalCurrentAssets: parseD(json['totalCurrentAssets']),
totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']),
currentLiabilities: parseD(json['currentLiabilities']),
longTermDebt: parseD(json['longTermDebt']),
totalLiabilities: parseD(json['totalLiabilities']),
totalStockholdersEquity: parseD(json['totalStockholdersEquity']),
operatingCashFlow: parseD(json['operatingCashFlow']),
investingCashFlow: parseD(json['investingCashFlow']),
capitalExpenditures: parseD(json['capitalExpenditures']),
financingCashFlow: parseD(json['financingCashFlow']),
freeCashFlow: parseD(json['freeCashFlow']),
);
}
Map<String, dynamic> toJson() {
return {
'periodType': periodType,
'endDate': endDate,
'totalRevenue': totalRevenue,
'costOfRevenue': costOfRevenue,
'grossProfit': grossProfit,
'operatingExpenses': operatingExpenses,
'operatingIncome': operatingIncome,
'ebitda': ebitda,
'netIncome': netIncome,
'epsBasic': epsBasic,
'epsDiluted': epsDiluted,
'cashAndCashEquivalents': cashAndCashEquivalents,
'accountsReceivable': accountsReceivable,
'inventory': inventory,
'totalCurrentAssets': totalCurrentAssets,
'totalNonCurrentAssets': totalNonCurrentAssets,
'currentLiabilities': currentLiabilities,
'longTermDebt': longTermDebt,
'totalLiabilities': totalLiabilities,
'totalStockholdersEquity': totalStockholdersEquity,
'operatingCashFlow': operatingCashFlow,
'investingCashFlow': investingCashFlow,
'capitalExpenditures': capitalExpenditures,
'financingCashFlow': financingCashFlow,
'freeCashFlow': freeCashFlow,
};
}
@override
List<Object?> get props => [
periodType,
endDate,
totalRevenue,
costOfRevenue,
grossProfit,
operatingExpenses,
operatingIncome,
ebitda,
netIncome,
epsBasic,
epsDiluted,
cashAndCashEquivalents,
accountsReceivable,
inventory,
totalCurrentAssets,
totalNonCurrentAssets,
currentLiabilities,
longTermDebt,
totalLiabilities,
totalStockholdersEquity,
operatingCashFlow,
investingCashFlow,
capitalExpenditures,
financingCashFlow,
freeCashFlow,
];
}
class ForwardEstimateModel extends Equatable {
final String period;
final double? expectedRevenue;
final double? expectedEps;
final double? expectedGrowthRate;
const ForwardEstimateModel({
required this.period,
this.expectedRevenue,
this.expectedEps,
this.expectedGrowthRate,
});
factory ForwardEstimateModel.fromJson(Map<String, dynamic> json) {
return ForwardEstimateModel(
period: json['period']?.toString() ?? '',
expectedRevenue: json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null,
expectedEps: json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null,
expectedGrowthRate: json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return {
'period': period,
'expectedRevenue': expectedRevenue,
'expectedEps': expectedEps,
'expectedGrowthRate': expectedGrowthRate,
};
}
@override
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
}
@@ -0,0 +1,51 @@
class ManualAnalysisRequestDto {
final String isin;
final String symbol;
final int riskScore;
final int minTimeframeValue;
final int maxTimeframeValue;
final String timeframeUnit;
final String instrumentType;
final String userNotes;
final String headline;
ManualAnalysisRequestDto({
required this.isin,
required this.symbol,
required this.riskScore,
required this.minTimeframeValue,
required this.maxTimeframeValue,
required this.timeframeUnit,
required this.instrumentType,
required this.userNotes,
required this.headline,
});
Map<String, dynamic> toJson() {
return {
'isin': isin,
'symbol': symbol,
'riskScore': riskScore,
'minTimeframeValue': minTimeframeValue,
'maxTimeframeValue': maxTimeframeValue,
'timeframeUnit': timeframeUnit,
'instrumentType': instrumentType,
'userNotes': userNotes,
'headline': headline,
};
}
factory ManualAnalysisRequestDto.fromJson(Map<String, dynamic> json) {
return ManualAnalysisRequestDto(
isin: json['isin'] as String,
symbol: json['symbol'] as String,
riskScore: json['riskScore'] as int,
minTimeframeValue: json['minTimeframeValue'] as int,
maxTimeframeValue: json['maxTimeframeValue'] as int,
timeframeUnit: json['timeframeUnit'] as String,
instrumentType: json['instrumentType'] as String,
userNotes: json['userNotes'] as String,
headline: json['headline'] as String,
);
}
}
@@ -0,0 +1,209 @@
import 'package:equatable/equatable.dart';
class CandleModel extends Equatable {
final DateTime timestamp;
final double open;
final double high;
final double low;
final double close;
final double volume;
const CandleModel({
required this.timestamp,
required this.open,
required this.high,
required this.low,
required this.close,
required this.volume,
});
factory CandleModel.fromJson(Map<String, dynamic> json) {
return CandleModel(
timestamp: DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now(),
open: (json['open'] as num?)?.toDouble() ?? 0.0,
high: (json['high'] as num?)?.toDouble() ?? 0.0,
low: (json['low'] as num?)?.toDouble() ?? 0.0,
close: (json['close'] as num?)?.toDouble() ?? 0.0,
volume: (json['volume'] as num?)?.toDouble() ?? 0.0,
);
}
@override
List<Object?> get props => [timestamp, open, high, low, close, volume];
}
class IndicatorModel extends Equatable {
final DateTime timestamp;
final double? ema20;
final double? sma50;
final double? sma200;
final double? rsi14;
final double? macdLine;
final double? macdSignal;
final double? macdHistogram;
final double? atr14;
final double? vwap;
final double? supertrendUpper;
final double? supertrendLower;
final String? supertrendDirection;
final double? recommendedStopLoss;
const IndicatorModel({
required this.timestamp,
this.ema20,
this.sma50,
this.sma200,
this.rsi14,
this.macdLine,
this.macdSignal,
this.macdHistogram,
this.atr14,
this.vwap,
this.supertrendUpper,
this.supertrendLower,
this.supertrendDirection,
this.recommendedStopLoss,
});
factory IndicatorModel.fromJson(Map<String, dynamic> json) {
return IndicatorModel(
timestamp: DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now(),
ema20: (json['ema20'] as num?)?.toDouble(),
sma50: (json['sma50'] as num?)?.toDouble(),
sma200: (json['sma200'] as num?)?.toDouble(),
rsi14: (json['rsi14'] as num?)?.toDouble(),
macdLine: (json['macdLine'] as num?)?.toDouble(),
macdSignal: (json['macdSignal'] as num?)?.toDouble(),
macdHistogram: (json['macdHistogram'] as num?)?.toDouble(),
atr14: (json['atr14'] as num?)?.toDouble(),
vwap: (json['vwap'] as num?)?.toDouble(),
supertrendUpper: (json['supertrendUpper'] as num?)?.toDouble(),
supertrendLower: (json['supertrendLower'] as num?)?.toDouble(),
supertrendDirection: json['supertrendDirection']?.toString(),
recommendedStopLoss: (json['recommendedStopLoss'] as num?)?.toDouble(),
);
}
@override
List<Object?> get props => [
timestamp, ema20, sma50, sma200, rsi14, macdLine, macdSignal,
macdHistogram, atr14, vwap, supertrendUpper, supertrendLower,
supertrendDirection, recommendedStopLoss
];
}
class StrategySignalModel extends Equatable {
final String title;
final DateTime date;
final double price;
final String type; // BUY or SELL
const StrategySignalModel({
required this.title,
required this.date,
required this.price,
required this.type,
});
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
return StrategySignalModel(
title: json['title']?.toString() ?? '',
date: DateTime.tryParse(json['date']?.toString() ?? '') ?? DateTime.now(),
price: (json['price'] as num?)?.toDouble() ?? 0.0,
type: json['type']?.toString() ?? 'BUY',
);
}
@override
List<Object?> get props => [title, date, price, type];
}
class TechnicalAnalysisModel extends Equatable {
final String symbol;
final String trend;
final String rsi;
final String macd;
final String overallSignal;
final String sma50;
final String sma200;
final double vix;
final String sp500Trend;
final double dxy;
final double? stopLossAtr;
final List<CandleModel> candles;
final List<IndicatorModel> indicators;
final List<String> patterns;
final List<StrategySignalModel> signals;
const TechnicalAnalysisModel({
required this.symbol,
required this.trend,
required this.rsi,
required this.macd,
required this.overallSignal,
required this.sma50,
required this.sma200,
this.vix = 16.5,
this.sp500Trend = 'Bullish',
this.dxy = 104.2,
this.stopLossAtr,
this.candles = const [],
this.indicators = const [],
this.patterns = const [],
this.signals = const [],
});
factory TechnicalAnalysisModel.fromJson(Map<String, dynamic> json) {
var rawCandles = json['candles'] as List<dynamic>? ?? [];
var candlesList = rawCandles.map((c) => CandleModel.fromJson(c as Map<String, dynamic>)).toList();
var rawIndicators = json['indicators'] as List<dynamic>? ?? [];
var indicatorsList = rawIndicators.map((i) => IndicatorModel.fromJson(i as Map<String, dynamic>)).toList();
var rawSignals = json['signals'] as List<dynamic>? ?? [];
var signalsList = rawSignals.map((s) => StrategySignalModel.fromJson(s as Map<String, dynamic>)).toList();
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
var patternsList = rawPatterns.map((p) => p.toString()).toList();
return TechnicalAnalysisModel(
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
trend: json['trend']?.toString() ?? json['Trend']?.toString() ?? 'Bullisch ▲',
rsi: json['rsi']?.toString() ?? json['Rsi']?.toString() ?? '58.7',
macd: json['macd']?.toString() ?? json['Macd']?.toString() ?? '0.45',
overallSignal: json['overallSignal']?.toString() ?? json['OverallSignal']?.toString() ?? 'HOLD',
sma50: json['sma50']?.toString() ?? json['Sma50']?.toString() ?? '49.50',
sma200: json['sma200']?.toString() ?? json['Sma200']?.toString() ?? '42.50',
vix: (json['vix'] as num?)?.toDouble() ?? 16.5,
sp500Trend: json['sp500Trend']?.toString() ?? 'Bullish',
dxy: (json['dxy'] as num?)?.toDouble() ?? 104.2,
stopLossAtr: (json['stopLossAtr'] as num?)?.toDouble(),
candles: candlesList,
indicators: indicatorsList,
patterns: patternsList,
signals: signalsList,
);
}
Map<String, dynamic> toJson() {
return {
'symbol': symbol,
'trend': trend,
'rsi': rsi,
'macd': macd,
'overallSignal': overallSignal,
'sma50': sma50,
'sma200': sma200,
'vix': vix,
'sp500Trend': sp500Trend,
'dxy': dxy,
'stopLossAtr': stopLossAtr,
};
}
@override
List<Object?> get props => [
symbol, trend, rsi, macd, overallSignal, sma50, sma200, vix,
sp500Trend, dxy, stopLossAtr, candles, indicators, patterns, signals
];
}
@@ -0,0 +1,145 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/asset_detail/models/asset_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/manual_analysis_request_dto.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
class AssetRepository {
final ApiClient apiClient;
AssetRepository({required this.apiClient});
Future<void> forceRefreshFundamentalData(String symbol) async {
try {
await apiClient.post('/api/v1/assets/$symbol/refresh');
} catch (e) {
print('Error forcing refresh: $e');
}
}
Future<FundamentalDataModel?> getFundamentalData(String symbol) async {
try {
final res = await apiClient.get('/api/v1/assets/fundamentals/$symbol');
if (res.statusCode == 200 && res.data != null) {
return FundamentalDataModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching fundamentals for $symbol: $e');
}
return null;
}
Future<TechnicalAnalysisModel?> getTechnicalAnalysis(String symbol) async {
try {
final res = await apiClient.get('/api/v1/ta/$symbol');
if (res.statusCode == 200 && res.data != null) {
return TechnicalAnalysisModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching TA for $symbol: $e');
}
return null;
}
Future<AssetModel?> getAssetHeader(String isin, {String? exchange, String? ticker}) async {
try {
String url = '/api/v1/assets/header/$isin?';
if (ticker != null && ticker.isNotEmpty) {
url += 'ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
return AssetModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching asset header for $isin: $e');
}
return null;
}
Future<FundamentalDataModel?> getAssetFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/assets/fundamentals/$isin?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
return FundamentalDataModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching fundamentals for $isin: $e');
}
return null;
}
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/ta/$isin?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
return TechnicalAnalysisModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching TA for $isin: $e');
}
return null;
}
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
try {
String url = '/api/v1/trades?isin=$isin';
if (status != null) url += '&status=$status';
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
final List<dynamic> list = res.data;
return list.map((json) => TradeModel.fromJson(json)).toList();
}
} catch (e) {
print('Error fetching trades for $isin: $e');
}
return [];
}
Future<void> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
try {
final body = payload != null ? payload.toJson() : {'isin': isin};
await apiClient.post('/api/v1/analyze/manual', data: body);
} catch (e) {
print('Error triggering manual analysis for $isin: $e');
throw e;
}
}
Future<void> rejectTrade(String tradeId) async {
try {
await apiClient.post('/api/v1/user/trades/$tradeId/reject');
} catch (e) {
print('Error rejecting trade $tradeId: $e');
throw e;
}
}
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async {
try {
final payload = tradeAcceptanceDto.toJson();
await apiClient.post('/api/v1/user/trades/accept', data: payload);
} catch (e) {
print('Error accepting trade: $e');
throw e;
}
}
Future<void> closeTrade(String tradeId, double exitPrice) async {
try {
await apiClient.post('/api/v1/user/trades/$tradeId/close', data: {'userExitPrice': exitPrice});
} catch (e) {
print('Error closing trade $tradeId: $e');
throw e;
}
}
}
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../widgets/metric_explanation_modal.dart';
class MetricExplanations {
static const Map<String, Map<String, String>> data = {
'EMA (20)': {
'title': 'Exponential Moving Average (20 Perioden)',
'formula': 'EMA_t = (Preis_t * (2 / (20 + 1))) + EMA_{t-1} * (1 - (2 / (20 + 1)))',
'description': 'Exponentiell gewichteter gleitender Durchschnitt der letzten 20 Kerzen. Gewichtete aktuelle Kurse stärker als ältere.',
'tradingSignificance': 'Dient als dynamische Unterstützung/Widerstand für kurzfristige Trends. Ein Schnittpunkt über die Kerzen zeigt Kaufsignale.',
},
'SMA (50)': {
'title': 'Simple Moving Average (50 Perioden)',
'formula': 'SMA = (Summe der Schlusskurse der letzten 50 Kerzen) / 50',
'description': 'Einfacher gleitender Durchschnitt der letzten 50 Perioden.',
'tradingSignificance': 'Standard-Indikator für mittelfristige Trends. Preis über SMA50 deutet auf einen intakten Aufwärtstrend hin.',
},
'SMA (200)': {
'title': 'Simple Moving Average (200 Perioden)',
'formula': 'SMA = (Summe der Schlusskurse der letzten 200 Kerzen) / 200',
'description': 'Einfacher gleitender Durchschnitt der letzten 200 Perioden.',
'tradingSignificance': 'Wichtigster Indikator für den langfristigen Trend. "Golden Cross" (SMA50 schneidet SMA200 nach oben) ist ein starkes Bullen-Signal.',
},
'Supertrend': {
'title': 'Supertrend Indikator',
'formula': 'Upper/Lower Band = (High + Low)/2 ± (Multiplier * ATR(10))',
'description': 'Kombiniert ATR (Average True Range) und Durchschnittskurs zur Trendfolge.',
'tradingSignificance': 'Grün zeigt einen etablierten Aufwärtstrend mit dynamischem Stop-Loss Level; Rot signalisiert Abwärtstrend.',
},
'RSI (14)': {
'title': 'Relative Strength Index (14 Perioden)',
'formula': 'RSI = 100 - (100 / (1 + (Durchschnittl. Gewinn / Durchschnittl. Verlust)))',
'description': 'Oszillator zur Messung der Geschwindigkeit und Veränderung von Kursbewegungen.',
'tradingSignificance': 'Werte > 70 gelten als überkauft (Verkaufsrisiko), Werte < 30 gelten als überverkauft (Kaufchance).',
},
'KGV (Trailing P/E)': {
'title': 'Kurs-Gewinn-Verhältnis (Trailing P/E)',
'formula': 'KGV = Aktienkurs / Gewinn pro Aktie (EPS der letzten 12 Monate)',
'description': 'Gibt an, das Wievielfache des Jahresgewinns für eine Aktie gezahlt wird.',
'tradingSignificance': 'Ein niedriges KGV kann auf eine Unterbewertung hindeuten; ein hohes KGV verlangt hohes zukünftiges Gewinnwachstum.',
},
'KGV (Forward P/E)': {
'title': 'Zukünftiges KGV (Forward P/E)',
'formula': 'Forward KGV = Aktueller Kurs / Erwarteter Gewinn pro Aktie (nächste 12 Monate)',
'description': 'Basiert auf den Konsens-Gewinnerwartungen von Analysten für das kommende Jahr.',
'tradingSignificance': 'Ermöglicht den Vergleich mit dem historischen KGV, um festzustellen, ob das Gewinnwachstum die Bewertung verbilligt.',
},
'PEG Ratio': {
'title': 'Price/Earnings-to-Growth Ratio',
'formula': 'PEG = KGV / Zukünftiges Gewinnwachstum in %',
'description': 'Setzt das KGV ins Verhältnis zum erwarteten Gewinnwachstum des Unternehmens.',
'tradingSignificance': 'PEG < 1.0 gilt als fair oder unterbewertet im Verhältnis zum Wachstum. PEG > 2.0 gilt als teuer.',
},
'KBV (P/B Ratio)': {
'title': 'Kurs-Buchwert-Verhältnis (P/B Ratio)',
'formula': 'KBV = Aktienkurs / Buchwert pro Aktie',
'description': 'Vergleicht den Börsenwert des Unternehmens mit seinem bilanziellen Eigenkapital.',
'tradingSignificance': 'Besonders wichtig für Finanzwerte und Substanzwerte. KBV < 1 bedeutet, dass die Aktie unter ihrem Buchwert handelt.',
},
'KUV (P/S Ratio)': {
'title': 'Kurs-Umsatz-Verhältnis (P/S Ratio)',
'formula': 'KUV = Marktkapitalisierung / Gesamter Jahresumsatz',
'description': 'Vergleicht den Marktwert des Unternehmens mit seinem Jahresumsatz.',
'tradingSignificance': 'Nützlich bei noch unprofitablen Wachstumsunternehmen, bei denen noch kein positives KGV berechnet werden kann.',
},
'EV / EBITDA': {
'title': 'Enterprise Value zu EBITDA',
'formula': 'EV/EBITDA = Enterprise Value / (Gewinn vor Zinsen, Steuern & Abschreibungen)',
'description': 'Misst den Unternehmenswert inklusive Schulden im Verhältnis zur operativen Cash-Generierung.',
'tradingSignificance': 'Kapitalstruktur-neutraler Bewertungs-Multiple. Erlaubt fairen Vergleich zwischen Unternehmen mit unterschiedlicher Verschuldung.',
},
'EV / Sales': {
'title': 'Enterprise Value zu Umsatz',
'formula': 'EV/Sales = Enterprise Value / Jahresumsatz',
'description': 'Vergleicht den gesamten Unternehmenswert (Eigen- + Fremdkapital) mit den Erlösen.',
'tradingSignificance': 'Robustere Kennzahl als KUV, da sie auch die Schuldenlast des Unternehmens berücksichtigt.',
},
'Enterprise Value': {
'title': 'Enterprise Value (Unternehmenswert)',
'formula': 'EV = Marktkapitalisierung + Gesamtschulden - Liquide Mittel (Cash)',
'description': 'Der theoretische Übernahmepreis für das gesamte Unternehmen inklusive Tilgung aller Verbindlichkeiten.',
'tradingSignificance': 'Der tatsächliche wirtschaftliche Wert des Geschäftsbetriebs.',
},
'Marktkapitalisierung': {
'title': 'Marktkapitalisierung (Market Cap)',
'formula': 'Market Cap = Gesamtzahl ausstehender Aktien * Aktueller Aktienkurs',
'description': 'Der Gesamtwert aller frei gehandelten Aktien des Unternehmens an der Börse.',
'tradingSignificance': 'Teilt Unternehmen in Large Cap (>10 Mrd. €), Mid Cap (2-10 Mrd. €) und Small Cap (<2 Mrd. €) ein.',
},
'Short Ratio': {
'title': 'Days to Cover (Short Ratio)',
'formula': 'Short Ratio = Anzahl leerverkaufter Aktien / Durchschnittliches Tagesvolumen',
'description': 'Gibt an, wie viele Handelstage Leerverkäufer bräuchten, um alle Positionen einzudecken.',
'tradingSignificance': 'Hohe Werte (> 5-7 Tage) erhöhen die Wahrscheinlichkeit eines heftigen "Short Squeezes" bei positiven News.',
},
'Bruttomarge (Gross)': {
'title': 'Bruttogewinnmarge (Gross Margin)',
'formula': 'Gross Margin = ((Umsatz - Herstellkosten) / Umsatz) * 100',
'description': 'Prozentualer Anteil des Umsatzes, der nach Abzug der direkten Produktionskosten verbleibt.',
'tradingSignificance': 'Hohe Bruttomargen (> 50-70%) zeigen eine starke Preissetzungsmacht und Wettbewerbsvorteile (Moat).',
},
'Operative Marge': {
'title': 'Operative Gewinnmarge (EBIT Margin)',
'formula': 'Operating Margin = (Operatives Ergebnis (EBIT) / Umsatz) * 100',
'description': 'Prozentualer Anteil des Umsatzes, der nach allen operativen Kosten (F&E, Vertrieb, Admin) übrig bleibt.',
'tradingSignificance': 'Kerngröße für die operative Effizienz des Managements.',
},
'Nettogewinnmarge': {
'title': 'Nettogewinnmarge (Net Profit Margin)',
'formula': 'Net Profit Margin = (Nettogewinn nach Steuern / Umsatz) * 100',
'description': 'Prozentualer Reingewinn, der von jedem Euro Umsatz im Unternehmen verbleibt.',
'tradingSignificance': 'Zeigt die finale Rentabilität nach allen Zinsen und Steuern.',
},
'Eigenkapitalrendite (ROE)': {
'title': 'Eigenkapitalrendite (Return on Equity)',
'formula': 'ROE = (Nettogewinn / Eigenkapital) * 100',
'description': 'Misst, wie effizient das Management das eingesetzte Eigenkapital verzinst.',
'tradingSignificance': 'Werte > 15-20% stehen für hochprofitable Qualitätsunternehmen.',
},
'Verschuldungsgrad (D/E)': {
'title': 'Debt-to-Equity Ratio (D/E)',
'formula': 'D/E = Gesamtschulden / Eigenkapital',
'description': 'Setzt das Fremdkapital ins Verhältnis zum Eigenkapital.',
'tradingSignificance': 'Werte > 1.5 - 2.0 deuten auf ein erhöhtes mehraufwand- und Insolvenzrisiko bei steigenden Zinsen hin.',
},
};
static void show(BuildContext context, String key) {
final info = data[key];
if (info == null) return;
showDialog(
context: context,
builder: (context) => MetricExplanationModal(
title: info['title']!,
formula: info['formula']!,
description: info['description']!,
tradingSignificance: info['tradingSignificance']!,
),
);
}
}
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class PatternExplanations {
static const Map<String, Map<String, String>> dictionary = {
'ASCENDING_TRIANGLE': {
'title': 'Steigendes Dreieck (Ascending Triangle)',
'bias': 'BULLISH',
'description': 'Ein bullisches Fortsetzungsmuster, das durch eine horizontale Widerstandslinie oben und eine steigende Unterstützungslinie unten gekennzeichnet ist.',
'significance': 'Käufer werden bei jedem Rücksetzer aggressiver (höhere Tiefs). Ein Ausbruch über die obere Widerstandslinie signalisiert eine starke Fortsetzung des Aufwärtstrends.',
'action': 'Kauf-Order / Breakout-Trade beim Ausbruch über den horizontalen Widerstand mit Stop-Loss knapp unter der steigenden Trendlinie.',
},
'DESCENDING_TRIANGLE': {
'title': 'Fallendes Dreieck (Descending Triangle)',
'bias': 'BEARISH',
'description': 'Ein bärisches Fortsetzungsmuster mit einer horizontalen Unterstützungslinie unten und fallenden Hochs oben.',
'significance': 'Verkäufer drücken den Kurs bei jeder Erholung schneller nach unten. Ein Bruch der unteren Unterstützung führt meist zu dynamischen Abverkäufen.',
'action': 'Short-Trade oder Verkauf bei Durchbruch der unteren Unterstützungslinie.',
},
'HEAD_AND_SHOULDERS': {
'title': 'Kopf-Schulter-Formation (Head & Shoulders)',
'bias': 'BEARISH',
'description': 'Klassisches Umkehrmuster bestehend aus drei Höchstständen: der mittleren höchsten Spitze (Kopf) und zwei kleineren Höchstständen links und rechts (Schultern).',
'significance': 'Ein nachhaltiger Bruch der Nackenlinie (Neckline) markiert das Ende eines Aufwärtstrends und den Beginn einer Bärenphase.',
'action': 'Verkauf/Short-Position beim Bruch der Nackenlinie mit Kursziel entsprechend der Distanz zwischen Kopf und Nackenlinie.',
},
'INVERSE_HEAD_AND_SHOULDERS': {
'title': 'Umgekehrte Kopf-Schulter-Formation',
'bias': 'BULLISH',
'description': 'Bullisches Bodenbildungsmuster nach einem Abwärtstrend mit drei Tiefspunkten.',
'significance': 'Signalisiert das Ende des Abwärtstrends und den Beginn eines neuen Bullenmarktes.',
'action': 'Kauf bei Ausbruch über die obere Nackenlinie.',
},
'BULL_FLAG': {
'title': 'Bullische Flagge (Bull Flag)',
'bias': 'BULLISH',
'description': 'Kurze Konsolidierung gegen den übergeordneten starken Aufwärtstrend (Fahnenstange).',
'significance': 'Zeigt eine temporäre Gewinnmitnahme vor der nächsten Welle nach oben.',
'action': 'Kauf beim Ausbruch aus der oberen Begrenzung des Flaggenkanals.',
},
'BEAR_FLAG': {
'title': 'Bärische Flagge (Bear Flag)',
'bias': 'BEARISH',
'description': 'Kurze Aufwärtskonsolidierung in einem steilen Abwärtstrend.',
'significance': 'Signalisiert eine Fortsetzung des steilen Abverkaufs.',
'action': 'Short-Position bei Durchbrechen der unteren Flaggenkante.',
},
'DOUBLE_BOTTOM': {
'title': 'Doppelboden (W-Formation)',
'bias': 'BULLISH',
'description': 'Zwei aufeinanderfolgende Tiefpunkte auf etwa gleichem Kursniveau.',
'significance': 'Starke Unterstützung auf dem Tiefststand wurde zweimal erfolgreich verteidigt. Ausbruch über das Zwischenhoch bestätigt W-Boden.',
'action': 'Kauf bei Überschreiten des W-Zwischenhochs.',
},
'DOUBLE_TOP': {
'title': 'Doppeltopp (M-Formation)',
'bias': 'BEARISH',
'description': 'Zwei markante Höchststände auf ähnlicher Höhe, die nicht durchbrochen werden konnten.',
'significance': 'Widerstandszone ist zu stark für die Bullen. Bruch des Zwischen-Tiefs leitet Trendwende ein.',
'action': 'Verkauf/Short bei Bruch des Zwischentiefs.',
},
'CHANNEL': {
'title': 'Trendkanal (Trading Channel)',
'bias': 'NEUTRAL',
'description': 'Parallele obere und untere Trendlinien, zwischen denen der Kurs Oszilliert.',
'significance': 'Erlaubt Swing-Trading zwischen den Kanallinien oder Breakout-Trading beim Ausbruch.',
'action': 'Kauf an der Unterkante, Verkauf an der Oberkante oder Breakout-Trading.',
},
'SUPPORT_RESISTANCE': {
'title': 'Unterstützungs- & Widerstandslinien',
'bias': 'NEUTRAL',
'description': 'Preisniveaus, an denen historisch gehäuft Kauf- oder Verkaufsinteresse auftrat.',
'significance': 'Wichtige Marken für Stop-Loss Platzierungen und Kursziele.',
'action': 'Trading an Key-Levels mit engem Risikomanagement.',
},
};
static void showPatternDetails(BuildContext context, String rawPatternType) {
final key = dictionary.keys.firstWhere(
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
orElse: () => '',
);
final info = key.isNotEmpty ? dictionary[key]! : {
'title': rawPatternType,
'bias': 'NEUTRAL',
'description': 'Ein vom FinlyticAnalyzer erkanntes technisches Chart-Muster ($rawPatternType).',
'significance': 'Trendlinien und Schlüssel-Zonen zur Bestimmung von Ein- und Ausstiegssignalen.',
'action': 'Nutzen Sie Stopp-Orders und beachten Sie den übergeordneten Markt-Trend.',
};
final isBullish = info['bias'] == 'BULLISH';
final isBearish = info['bias'] == 'BEARISH';
final biasColor = isBullish ? AppTheme.primaryEmerald : (isBearish ? AppTheme.accentRed : AppTheme.accentCyan);
showDialog(
context: context,
builder: (modalContext) => AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
info['title']!,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: biasColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: biasColor),
),
child: Text(
info['bias']!,
style: TextStyle(color: biasColor, fontWeight: FontWeight.bold, fontSize: 11),
),
),
],
),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Formationsbeschreibung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Text(info['description']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13, height: 1.4)),
const SizedBox(height: 14),
const Text('Markt-Bedeutung & Psychologie:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Text(info['significance']!, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
const SizedBox(height: 14),
const Text('Empfohlene Trading-Handlung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Text(info['action']!, style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.w600, height: 1.4)),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(modalContext),
child: const Text('Schließen', style: TextStyle(color: Colors.white)),
),
],
),
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../bloc/fundamentals/asset_fundamentals_event.dart';
import '../bloc/header/asset_header_bloc.dart';
import '../bloc/header/asset_header_event.dart';
import '../bloc/technical/asset_technical_bloc.dart';
import '../bloc/technical/asset_technical_event.dart';
import '../bloc/trades/asset_trades_bloc.dart';
import '../bloc/trades/asset_trades_event.dart';
import '../repositories/asset_repository.dart';
import 'layouts/asset_page_desktop_layout.dart';
import 'layouts/asset_page_mobile_layout.dart';
class AssetDetailScreen extends StatelessWidget {
final String symbol;
final ApiClient apiClient;
const AssetDetailScreen({
super.key,
required this.symbol,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
final repository = AssetRepository(apiClient: apiClient);
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AssetHeaderBloc(repository: repository)..add(LoadAssetHeader(symbol)),
),
BlocProvider(
create: (context) => AssetFundamentalsBloc(repository: repository)..add(LoadAssetFundamentals(symbol)),
),
BlocProvider(
create: (context) => AssetTechnicalBloc(repository: repository)..add(LoadAssetTechnical(symbol)),
),
BlocProvider(
create: (context) => AssetTradesBloc(repository: repository)..add(LoadAssetTrades(symbol)),
),
],
child: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 900) {
return AssetPageDesktopLayout(symbol: symbol);
}
return AssetPageMobileLayout(symbol: symbol);
},
),
),
);
}
}
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_event.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart';
import '../tabs/trades_tab.dart';
class AssetPageDesktopLayout extends StatefulWidget {
final String symbol;
const AssetPageDesktopLayout({super.key, required this.symbol});
@override
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
}
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout> with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedExchange;
String? _selectedTicker;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
_selectedExchange = newExchange;
_selectedTicker = newTicker;
});
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.symbol, exchange: newExchange, ticker: newTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: newTicker, forceRefresh: false));
final favCubit = context.read<FavoritesCubit>();
if (favCubit.state.isFavorite(widget.symbol)) {
favCubit.updateFavoriteTicker(widget.symbol, newTicker);
}
}
void _handleForceRefresh() {
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.symbol, forceRefresh: true, exchange: _selectedExchange, ticker: _selectedTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
listener: (context, state) {
if (state is AssetHeaderLoaded && state.data != null) {
if (_selectedTicker == null) {
setState(() {
_selectedTicker = state.data!.symbol;
_selectedExchange = state.data!.exchange;
});
// Re-trigger fundamentals and TA with resolved ticker
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: state.data!.symbol, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: state.data!.symbol, forceRefresh: false));
}
}
},
child: LayoutBuilder(
builder: (context, constraints) {
final height = constraints.maxHeight.isFinite ? constraints.maxHeight : MediaQuery.of(context).size.height;
return SizedBox(
height: height,
width: double.infinity,
child: Column(
children: [
AssetHeroHeader(
symbol: widget.symbol,
selectedExchange: _selectedExchange,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left Panel (Chart Focus)
Expanded(
flex: 5,
child: Container(
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(symbol: _selectedTicker ?? widget.symbol, isDesktopLeftPanel: true),
),
),
// Right Panel (Tabs for fundamentals/trades)
Expanded(
flex: 3,
child: Container(
margin: const EdgeInsets.only(top: 16, right: 16, bottom: 16),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: Column(
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(text: 'OVERVIEW'),
Tab(text: 'TRADES'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
FundamentalsTab(symbol: _selectedTicker ?? widget.symbol),
TradesTab(symbol: widget.symbol),
],
),
),
],
),
),
),
],
),
),
],
),
);
},
),
);
}
}
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_event.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart';
import '../tabs/trades_tab.dart';
class AssetPageMobileLayout extends StatefulWidget {
final String symbol;
const AssetPageMobileLayout({super.key, required this.symbol});
@override
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
}
class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout> with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedExchange;
String? _selectedTicker;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
_selectedExchange = newExchange;
_selectedTicker = newTicker;
});
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.symbol, exchange: newExchange, ticker: newTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: newTicker, forceRefresh: false));
final favCubit = context.read<FavoritesCubit>();
if (favCubit.state.isFavorite(widget.symbol)) {
favCubit.updateFavoriteTicker(widget.symbol, newTicker);
}
}
void _handleForceRefresh() {
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.symbol, forceRefresh: true, exchange: _selectedExchange, ticker: _selectedTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
listener: (context, state) {
if (state is AssetHeaderLoaded && state.data != null) {
if (_selectedTicker == null) {
setState(() {
_selectedTicker = state.data!.symbol;
_selectedExchange = state.data!.exchange;
});
// Re-trigger fundamentals and TA with resolved ticker
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: state.data!.symbol, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: state.data!.symbol, forceRefresh: false));
}
}
},
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverToBoxAdapter(
child: AssetHeroHeader(
symbol: widget.symbol,
selectedExchange: _selectedExchange,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
),
),
SliverPersistentHeader(
pinned: true,
delegate: _SliverAppBarDelegate(
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: Colors.transparent,
labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(text: 'OVERVIEW'),
Tab(text: 'TECHNICAL'),
Tab(text: 'TRADES'),
],
),
theme.cardSurface,
),
),
];
},
body: TabBarView(
controller: _tabController,
children: [
FundamentalsTab(symbol: _selectedTicker ?? widget.symbol),
TechnicalTab(symbol: _selectedTicker ?? widget.symbol),
TradesTab(symbol: widget.symbol),
],
),
),
);
}
}
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;
}
}
@@ -0,0 +1,630 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/fundamental_data_model.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../utils/metric_explanations.dart';
class FundamentalsTab extends StatefulWidget {
final String symbol;
const FundamentalsTab({super.key, required this.symbol});
@override
State<FundamentalsTab> createState() => _FundamentalsTabState();
}
class _FundamentalsTabState extends State<FundamentalsTab> {
String _selectedPeriodType = 'Annual'; // 'Annual' or 'Quarterly'
String _selectedStatementType = 'Income'; // 'Income', 'Balance', 'CashFlow'
@override
void initState() {
super.initState();
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: false));
}
@override
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.symbol != widget.symbol) {
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: false));
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
builder: (context, state) {
if (state is AssetFundamentalsLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is AssetFundamentalsError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (state is AssetFundamentalsLoaded) {
final data = state.data;
if (data == null) {
return _buildEmptyState();
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Analyst Forecasts & Price Targets Header Card
_buildPriceTargetCard(data),
const SizedBox(height: 20),
// 2. Valuation Multiples & Ratios
_buildSectionHeader('Bewertungskennzahlen & Multiples', Icons.analytics_outlined),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
_buildMetricCard('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
_buildMetricCard('PEG Ratio', _fmtMultiple(data.pegRatio)),
_buildMetricCard('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
_buildMetricCard('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
_buildMetricCard('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
_buildMetricCard('EV / Sales', _fmtMultiple(data.evToRevenue)),
_buildMetricCard('Enterprise Value', _formatNumber(data.enterpriseValue)),
_buildMetricCard('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
_buildMetricCard('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
_buildMetricCard('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
_buildMetricCard('Short Ratio', _fmtMultiple(data.shortRatio)),
],
),
const SizedBox(height: 24),
// 3. Profitability & Financial Health Margins
_buildSectionHeader('Rentabilität & Finanzielle Gesundheit', Icons.account_balance_outlined),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
_buildMetricCard('Operative Marge', _fmtPercent(data.operatingMargin)),
_buildMetricCard('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
_buildMetricCard('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
_buildMetricCard('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
_buildMetricCard('ROIC (Invested Capital)', _fmtPercent(data.returnOnInvestedCapital)),
_buildMetricCard('Verschuldungsgrad (D/E)', _fmtMultiple(data.debtToEquity)),
_buildMetricCard('Current Ratio', _fmtMultiple(data.currentRatio)),
_buildMetricCard('Quick Ratio', _fmtMultiple(data.quickRatio)),
_buildMetricCard('Zinsdeckungsgrad', _fmtMultiple(data.interestCoverage)),
],
),
const SizedBox(height: 24),
// 4. Dividends & Ownership
_buildSectionHeader('Dividenden & Aktionärsstruktur', Icons.pie_chart_outline),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('Dividendenrendite', _fmtPercent(data.dividendYield)),
_buildMetricCard('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
_buildMetricCard('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
_buildMetricCard('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
_buildMetricCard('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
_buildMetricCard('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
_buildMetricCard('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
],
),
const SizedBox(height: 24),
// 5. Financial Statements Section
_buildSectionHeader('Finanzberichte (Statements)', Icons.article_outlined),
const SizedBox(height: 12),
_buildStatementsSection(data),
const SizedBox(height: 24),
// 6. Company Description & Detailed Executive Board
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
const SizedBox(height: 12),
_buildProfileSection(data),
],
),
);
}
return _buildEmptyState();
},
);
}
Widget _buildPriceTargetCard(FundamentalDataModel data) {
final rating = data.consensusRating ?? 'N/A';
final targetMean = data.priceTargetMean;
final targetLow = data.priceTargetLow;
final targetHigh = data.priceTargetHigh;
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 8),
const Text('Analysten-Konsens & Kursziele', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
),
StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed),
_buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald),
_buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan),
],
),
],
),
);
}
Widget _buildTargetStat(String title, String val, Color col) {
return Column(
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
],
);
}
Widget _buildStatementsSection(FundamentalDataModel data) {
// Filter statements by Jährlich / Quartal
final filteredStatements = data.financialStatements
.where((s) => s.periodType.toLowerCase() == _selectedPeriodType.toLowerCase())
.toList();
// Sort descending by date
filteredStatements.sort((a, b) => b.endDate.compareTo(a.endDate));
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row containing switches
Row(
children: [
// 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)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'Keine Berichte für diesen Typ vorhanden.',
style: TextStyle(color: AppTheme.textMuted, fontStyle: FontStyle.italic),
),
),
)
else
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Table(
defaultColumnWidth: const FixedColumnWidth(110),
columnWidths: const {
0: FixedColumnWidth(180), // First column containing label is wider
},
border: TableBorder(
horizontalInside: BorderSide(color: Colors.white, width: 0.5),
),
children: _buildTableRows(filteredStatements),
),
),
],
),
);
}
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: [
_buildTableCell('Kennzahl (in EUR)', isHeader: true),
...statements.map((s) => _buildTableCell(_fmtDate(s.endDate), isHeader: true)),
],
),
);
if (_selectedStatementType == 'Income') {
rows.add(_buildDataRow('Umsatzerlöse', statements.map((s) => s.totalRevenue).toList()));
rows.add(_buildDataRow('Umsatzkosten', statements.map((s) => s.costOfRevenue).toList()));
rows.add(_buildDataRow('Bruttogewinn', statements.map((s) => s.grossProfit).toList()));
rows.add(_buildDataRow('Operative Aufwendungen', statements.map((s) => s.operatingExpenses).toList()));
rows.add(_buildDataRow('Operatives Ergebnis (EBIT)', statements.map((s) => s.operatingIncome).toList()));
rows.add(_buildDataRow('EBITDA', statements.map((s) => s.ebitda).toList()));
rows.add(_buildDataRow('Jahresüberschuss', statements.map((s) => s.netIncome).toList()));
rows.add(_buildDataRow('EPS (Basic)', statements.map((s) => s.epsBasic).toList(), isCurrency: true));
rows.add(_buildDataRow('EPS (Diluted)', statements.map((s) => s.epsDiluted).toList(), isCurrency: true));
} else if (_selectedStatementType == 'Balance') {
rows.add(_buildDataRow('Liquide Mittel', statements.map((s) => s.cashAndCashEquivalents).toList()));
rows.add(_buildDataRow('Forderungen', statements.map((s) => s.accountsReceivable).toList()));
rows.add(_buildDataRow('Vorräte', statements.map((s) => s.inventory).toList()));
rows.add(_buildDataRow('Umlaufvermögen (Current Assets)', statements.map((s) => s.totalCurrentAssets).toList()));
rows.add(_buildDataRow('Anlagevermögen (Non-Current)', statements.map((s) => s.totalNonCurrentAssets).toList()));
rows.add(_buildDataRow('Kurzfr. Verbindlichkeiten', statements.map((s) => s.currentLiabilities).toList()));
rows.add(_buildDataRow('Langfristige Schulden', statements.map((s) => s.longTermDebt).toList()));
rows.add(_buildDataRow('Gesamtverbindlichkeiten', statements.map((s) => s.totalLiabilities).toList()));
rows.add(_buildDataRow('Eigenkapital (Equity)', statements.map((s) => s.totalStockholdersEquity).toList()));
} else {
rows.add(_buildDataRow('Operativer Cashflow', statements.map((s) => s.operatingCashFlow).toList()));
rows.add(_buildDataRow('Investiver Cashflow', statements.map((s) => s.investingCashFlow).toList()));
rows.add(_buildDataRow('Investitionsausgaben (CapEx)', statements.map((s) => s.capitalExpenditures).toList()));
rows.add(_buildDataRow('Finanzierungs-Cashflow', statements.map((s) => s.financingCashFlow).toList()));
rows.add(_buildDataRow('Free Cashflow', statements.map((s) => s.freeCashFlow).toList()));
}
return rows;
}
TableRow _buildDataRow(String label, List<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,
),
),
);
}
Widget _buildProfileSection(FundamentalDataModel data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data.sector != null || data.industry != null || data.country != null) ...[
Row(
children: [
if (data.sector != null) ...[
_buildProfileBadge(data.sector!, Icons.category_outlined),
const SizedBox(width: 8),
],
if (data.country != null)
_buildProfileBadge(data.country!, Icons.place_outlined),
],
),
const SizedBox(height: 12),
],
Text(
data.businessSummary != null && data.businessSummary!.isNotEmpty
? data.businessSummary!
: 'Keine Beschreibung für dieses Asset verfügbar.',
style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13),
),
if (data.employees != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
'Mitarbeiter: ${data.employees}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
],
],
),
),
if (data.executives.isNotEmpty) ...[
const SizedBox(height: 16),
const Text('Führungskräfte (Board)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14)),
const SizedBox(height: 8),
...data.executives.take(5).map((e) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.person_outline, color: AppTheme.primaryEmerald, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(e.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
Text(e.title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
if (e.compensation != null && e.compensation! > 0)
Text(
_formatNumber(e.compensation),
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12),
),
],
),
),
)),
],
],
);
}
Widget _buildProfileBadge(String label, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11)),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.insert_chart_outlined, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text('Für dieses Asset wurden noch keine Bilanz- oder Bewertungskennzahlen erfasst.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: true)),
icon: const Icon(Icons.download),
label: const Text('Daten von Backend abrufen'),
),
],
),
),
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
);
}
Widget _buildMetricCard(String label, String value) {
return InkWell(
onTap: () => MetricExplanations.show(context, label),
borderRadius: BorderRadius.circular(10),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
label,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
],
),
const SizedBox(height: 4),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
],
),
),
);
}
String _fmtMultiple(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? '${n.toStringAsFixed(2)}x' : 'N/A';
}
String _fmtPercent(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
if (n == null) return 'N/A';
final p = (n > 0 && n <= 1) ? n * 100 : n;
return '${p.toStringAsFixed(2)}%';
}
String _fmtCurrency(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? '${n.toStringAsFixed(2)}' : 'N/A';
}
String _fmtDate(dynamic val) {
if (val == null) return 'N/A';
final dt = DateTime.tryParse(val.toString());
return dt != null ? '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}' : val.toString();
}
String _formatNumber(dynamic val) {
if (val == null) return 'N/A';
final num? n = val is num ? val : num.tryParse(val.toString());
if (n == null) return val.toString();
final isNegative = n < 0;
final absVal = n.abs();
final prefix = isNegative ? '-€' : '';
if (absVal >= 1e12) {
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bil.';
} else if (absVal >= 1e9) {
return '$prefix${(absVal / 1e9).toStringAsFixed(2)} Mrd.';
} else if (absVal >= 1e6) {
return '$prefix${(absVal / 1e6).toStringAsFixed(2)} Mio.';
} else if (absVal >= 1e3) {
return '$prefix${(absVal / 1e3).toStringAsFixed(2)} Tsd.';
} else {
return '$prefix${absVal.toStringAsFixed(2)}';
}
}
}
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
class NewsTab extends StatelessWidget {
final String symbol;
const NewsTab({super.key, required this.symbol});
@override
Widget build(BuildContext context) {
return const Center(child: Text('News Data', style: TextStyle(color: Colors.white)));
}
}
@@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/technical/asset_technical_state.dart';
import '../../utils/metric_explanations.dart';
import '../../utils/pattern_explanations.dart';
import '../../widgets/chart/candlestick_chart.dart';
class TechnicalTab extends StatefulWidget {
final String symbol;
final bool isDesktopLeftPanel;
const TechnicalTab({
super.key,
required this.symbol,
this.isDesktopLeftPanel = false,
});
@override
State<TechnicalTab> createState() => _TechnicalTabState();
}
class _TechnicalTabState extends State<TechnicalTab> {
bool _showSma50 = true;
bool _showSma200 = true;
bool _showEma = true;
bool _showPatterns = true;
bool _showSignals = true;
bool _showSupertrend = true;
// Set of disabled pattern indices for individual toggling
final Set<int> _disabledPatternIndices = {};
@override
void initState() {
super.initState();
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, forceRefresh: false));
}
@override
void didUpdateWidget(covariant TechnicalTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.symbol != widget.symbol) {
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, forceRefresh: false));
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
builder: (context, state) {
if (state is AssetTechnicalLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is AssetTechnicalError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.show_chart, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
Text('Fehler beim Laden der Technischen Analyse: ${state.message}', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (state is AssetTechnicalLoaded) {
final data = state.data;
List<CandleModel> candles = [];
List<ChartPatternModel> patterns = [];
List<StrategySignalModel> signals = [];
List<IndicatorModel> indicators = [];
if (data != null) {
candles = data.candles.map((c) => CandleModel(time: c.timestamp, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume)).toList();
patterns = []; // Since data.patterns is a List of Strings, we don't have point coordinates to draw them on the chart
signals = data.signals.map((s) => StrategySignalModel(type: 'strategy', timestamp: s.date, direction: s.type, price: s.price, description: s.title)).toList();
indicators = data.indicators.map((i) => IndicatorModel(timestamp: i.timestamp, ema20: i.ema20, sma50: i.sma50, sma200: i.sma200, supertrendUpper: i.supertrendUpper, supertrendLower: i.supertrendLower, supertrendDirection: i.supertrendDirection)).toList();
}
// Filter patterns according to individual checkbox states
final activePatterns = [
for (int i = 0; i < patterns.length; i++)
if (!_disabledPatternIndices.contains(i)) patterns[i]
];
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Glassmorphic Indicator & Pattern Control Ribbon
GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildIndicatorChip('EMA (20)', _showEma, (v) => setState(() => _showEma = v), Colors.blueAccent),
const SizedBox(width: 6),
_buildIndicatorChip('SMA (50)', _showSma50, (v) => setState(() => _showSma50 = v), Colors.orangeAccent),
const SizedBox(width: 6),
_buildIndicatorChip('SMA (200)', _showSma200, (v) => setState(() => _showSma200 = v), Colors.redAccent),
const SizedBox(width: 6),
_buildIndicatorChip('Supertrend', _showSupertrend, (v) => setState(() => _showSupertrend = v), AppTheme.primaryEmerald),
const SizedBox(width: 6),
_buildIndicatorChip('Alle Muster', _showPatterns, (v) => setState(() => _showPatterns = v), Colors.amberAccent),
const SizedBox(width: 6),
_buildIndicatorChip('Signale', _showSignals, (v) => setState(() => _showSignals = v), AppTheme.accentCyan),
],
),
),
),
const SizedBox(height: 8),
// Interactive Candlestick Chart
SizedBox(
height: 380,
child: CandlestickChart(
candles: candles,
patterns: activePatterns,
signals: signals,
indicators: indicators,
showPatterns: _showPatterns,
showEma: _showEma,
showSma50: _showSma50,
showSma200: _showSma200,
showSignals: _showSignals,
showSupertrend: _showSupertrend,
),
),
const SizedBox(height: 16),
// Dedicated Chart Patterns & Signal Description List Section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.architecture_outlined, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
const Text('Erkannte Chart-Muster & Signale', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
),
if (patterns.isNotEmpty)
TextButton.icon(
onPressed: () {
setState(() {
if (_disabledPatternIndices.length == patterns.length) {
_disabledPatternIndices.clear();
} else {
_disabledPatternIndices.addAll(List.generate(patterns.length, (i) => i));
}
});
},
icon: Icon(_disabledPatternIndices.isEmpty ? Icons.deselect : Icons.select_all, size: 16, color: Colors.amberAccent),
label: Text(_disabledPatternIndices.isEmpty ? 'Alle abwählen' : 'Alle anwählen', style: const TextStyle(color: Colors.amberAccent, fontSize: 12)),
),
],
),
const SizedBox(height: 12),
if (patterns.isEmpty && signals.isEmpty)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
),
)
else ...[
if (patterns.isNotEmpty) ...[
Text('Formationen & Trendlinien (Mit Checkbox im Chart schalten):', style: TextStyle(color: AppTheme.textSecondary, fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 6),
...List.generate(patterns.length, (index) => _buildPatternCard(patterns[index], index)),
const SizedBox(height: 12),
],
if (signals.isNotEmpty) ...[
Text('Strategie-Signale:', style: TextStyle(color: AppTheme.textSecondary, fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 6),
...signals.map((s) => _buildSignalCard(s)),
],
],
],
),
),
const SizedBox(height: 16),
],
),
);
}
return Center(
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
);
},
);
}
Widget _buildPatternCard(ChartPatternModel pattern, int index) {
final isEnabled = !_disabledPatternIndices.contains(index);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
// Checkbox for individual pattern toggling on the chart
Checkbox(
value: isEnabled,
activeColor: Colors.amberAccent,
checkColor: Colors.black,
side: BorderSide(color: Colors.amberAccent.withValues(alpha: 0.6)),
onChanged: (bool? val) {
setState(() {
if (val == true) {
_disabledPatternIndices.remove(index);
} else {
_disabledPatternIndices.add(index);
}
});
},
),
Expanded(
child: InkWell(
onTap: () => PatternExplanations.showPatternDetails(context, pattern.type),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isEnabled ? Colors.amberAccent.withValues(alpha: 0.15) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.polyline_outlined, color: isEnabled ? Colors.amberAccent : AppTheme.textMuted, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pattern.type,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isEnabled ? Colors.white : AppTheme.textMuted,
fontSize: 14,
decoration: isEnabled ? null : TextDecoration.lineThrough,
),
),
const SizedBox(width: 6),
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
],
),
const SizedBox(height: 4),
Text(
'Formationspunkte: Oberer Trendkanal (${pattern.upperLine.length} Pkt.) / Unterer Trendkanal (${pattern.lowerLine.length} Pkt.)',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
],
),
),
StatusBadge(
label: isEnabled ? 'AKTIV' : 'AUS',
color: isEnabled ? Colors.amberAccent : AppTheme.textMuted,
),
],
),
),
),
),
],
),
),
);
}
Widget _buildSignalCard(StrategySignalModel signal) {
final isBuy = signal.type.toUpperCase() == 'BUY';
final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(isBuy ? Icons.north_east : Icons.south_east, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(signal.type.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 14)),
const SizedBox(width: 8),
Text('@ €${signal.price.toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
],
),
const SizedBox(height: 4),
Text(signal.description.isNotEmpty ? signal.description : 'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
],
),
),
StatusBadge(label: 'SIGNAL', color: color),
],
),
),
);
}
Widget _buildIndicatorChip(String label, bool isSelected, ValueChanged<bool> onChanged, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
FilterChip(
selected: isSelected,
label: Text(label, style: TextStyle(color: isSelected ? Colors.black : color, fontSize: 11, fontWeight: FontWeight.bold)),
selectedColor: color,
backgroundColor: color.withValues(alpha: 0.15),
side: BorderSide(color: color.withValues(alpha: 0.4)),
showCheckmark: false,
onSelected: onChanged,
),
InkWell(
onTap: () => MetricExplanations.show(context, label),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
),
),
],
);
}
}
@@ -0,0 +1,915 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
import 'package:finlytic_app/features/trades/widgets/trade_execution_dialog.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../bloc/trades/asset_trades_state.dart';
class TradesTab extends StatefulWidget {
final String symbol;
const TradesTab({super.key, required this.symbol});
@override
State<TradesTab> createState() => _TradesTabState();
}
class _TradesTabState extends State<TradesTab> {
bool _justTriggeredAnalysis = false;
// Settings State
double _defaultPositionSize = 2500.0;
double _defaultLeverage = 5.0;
double _defaultRiskScore = 50.0;
double _defaultOrderFee = 1.0;
bool _autoAcceptSignals = false;
@override
void initState() {
super.initState();
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
void _showLiveTradeSettingsDialog(BuildContext context) {
double tempPos = _defaultPositionSize;
double tempLev = _defaultLeverage;
double tempRisk = _defaultRiskScore;
double tempFee = _defaultOrderFee;
bool tempAuto = _autoAcceptSignals;
final posController = TextEditingController(text: tempPos.toStringAsFixed(0));
final levController = TextEditingController(text: tempLev.toStringAsFixed(1));
final feeController = TextEditingController(text: tempFee.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (builderContext, setModalState) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.settings, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('Live Trade Einstellungen', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 440,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Standard Trade-Vorgaben für Ihr Depot:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: posController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Investment (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempPos = double.tryParse(v) ?? tempPos,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: levController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempLev = double.tryParse(v) ?? tempLev,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: feeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Ordergebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempFee = double.tryParse(v) ?? tempFee,
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Standard Risiko-Toleranz:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text('${tempRisk.toInt()}/100', style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 13)),
],
),
Slider(
value: tempRisk,
min: 0,
max: 100,
divisions: 100,
activeColor: AppTheme.primaryEmerald,
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => tempRisk = val),
),
const SizedBox(height: 10),
SwitchListTile(
value: tempAuto,
activeThumbColor: AppTheme.primaryEmerald,
title: const Text('KI-Signale automatisch annehmen', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)),
subtitle: Text('Führt eingehende Signale direkt im Depot aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
onChanged: (val) => setModalState(() => tempAuto = val),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
setState(() {
_defaultPositionSize = tempPos;
_defaultLeverage = tempLev;
_defaultRiskScore = tempRisk;
_defaultOrderFee = tempFee;
_autoAcceptSignals = tempAuto;
});
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Live Trade Einstellungen gespeichert.'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.save),
label: const Text('Einstellungen Speichern'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
),
),
],
);
},
);
},
);
}
void _showCloseTradeDialog(BuildContext context, TradeModel trade) {
final tradesBloc = context.read<AssetTradesBloc>();
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.flag_outlined, color: AppTheme.accentRed, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('Trade Position Schließen', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : widget.symbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
TextField(
controller: exitController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Tatsächlicher Ausstiegskurs (€)',
hintText: 'Z.B. 105.50',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final exitPrice = double.tryParse(exitController.text) ?? entry;
final tradeId = trade.id;
if (tradeId.isNotEmpty) {
tradesBloc.add(CloseTradeEvent(tradeId, widget.symbol, exitPrice));
}
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade $tradeId geschlossen zu €${exitPrice.toStringAsFixed(2)}.'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.check_circle),
label: const Text('Position Schließen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
),
),
],
);
},
);
}
void _showAnalysisParametersDialog(BuildContext context) {
final tradesBloc = context.read<AssetTradesBloc>();
final minTimeframeController = TextEditingController(text: '1');
final maxTimeframeController = TextEditingController(text: '14');
double riskScore = _defaultRiskScore;
String timeframeUnit = 'Tage';
String instrumentType = 'Aktie / ETF (Direktinvestment)';
final notesController = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (builderContext, setModalState) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('KI-Analyse Konfigurieren', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Asset / ISIN: ${widget.symbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
// 1. Haltedauer von - bis mit Einheit
const Text('Geplante Haltedauer:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: minTimeframeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: maxTimeframeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: timeframeUnit,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
items: const [
DropdownMenuItem(value: 'Stunden', child: Text('Stunden')),
DropdownMenuItem(value: 'Tage', child: Text('Tage')),
DropdownMenuItem(value: 'Wochen', child: Text('Wochen')),
DropdownMenuItem(value: 'Monate', child: Text('Monate')),
],
onChanged: (val) {
if (val != null) setModalState(() => timeframeUnit = val);
},
),
),
],
),
const SizedBox(height: 16),
// 2. Risikobereitschaft 0-100 Slider
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text(
'${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})',
style: TextStyle(color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
Slider(
value: riskScore,
min: 0,
max: 100,
divisions: 100,
activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => riskScore = val),
),
const SizedBox(height: 12),
// 3. Instrumententyp (Trade Republic typisch)
const Text('Instrumententyp (Trade Republic):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: instrumentType,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)),
items: const [
DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')),
DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')),
DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo)')),
DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')),
DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')),
],
onChanged: (val) {
if (val != null) setModalState(() => instrumentType = val);
},
),
const SizedBox(height: 16),
// 4. Anmerkung für die KI
const Text('Anmerkung für die KI:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
TextField(
controller: notesController,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...',
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final payload = ManualAnalysisRequestDto(
isin: widget.symbol,
symbol: widget.symbol,
riskScore: riskScore.toInt(),
minTimeframeValue: int.tryParse(minTimeframeController.text) ?? 1,
maxTimeframeValue: int.tryParse(maxTimeframeController.text) ?? 14,
timeframeUnit: timeframeUnit,
instrumentType: instrumentType,
userNotes: notesController.text,
headline: 'Manuelle KI-Analyse für ${widget.symbol}',
);
setState(() {
_justTriggeredAnalysis = true;
});
tradesBloc.add(TriggerManualAnalysis(widget.symbol, payload: payload));
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'),
backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.flash_on),
label: const Text('Analyse Jetzt Ausführen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
),
),
],
);
},
);
},
);
}
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
final tradesBloc = context.read<AssetTradesBloc>();
TradeExecutionDialog.show(
context,
trade: trade,
defaultSymbol: widget.symbol,
isActive: isActive,
onAccept: (dto) {
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
final tId = trade.id;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isActive
? 'Einstellungen für Trade $tId gespeichert!'
: 'Trade $tId angenommen & Position eröffnet!'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
onReject: (tId) {
tradesBloc.add(RejectTradeEvent(tId, widget.symbol));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade $tId abgelehnt.'),
backgroundColor: AppTheme.textSecondary,
behavior: SnackBarBehavior.floating,
),
);
},
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
listener: (context, state) {
if (_justTriggeredAnalysis && state is AssetTradesLoaded) {
final List<TradeModel> tradesList = state.data;
if (tradesList.isNotEmpty) {
_justTriggeredAnalysis = false;
final latestTrade = tradesList.first;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showEditTradeExecutionDialog(context, latestTrade);
});
}
}
},
builder: (context, state) {
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Action Button & Settings Card
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Trade & Signal Management', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Colors.white)),
const SizedBox(height: 4),
Text('KI-gestützte technische & fundamentale Trade-Analyse anfordern', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(width: 8),
StatusBadge(label: widget.symbol, color: AppTheme.accentCyan),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showAnalysisParametersDialog(context),
icon: const Icon(Icons.auto_awesome, size: 18),
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(width: 10),
IconButton.filledTonal(
onPressed: () => _showLiveTradeSettingsDialog(context),
icon: const Icon(Icons.settings, color: Colors.white),
tooltip: 'Live Trade Einstellungen',
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
padding: const EdgeInsets.all(14),
),
),
],
),
],
),
),
const SizedBox(height: 20),
if (state is AssetTradesLoading)
Center(child: Padding(padding: const EdgeInsets.all(32), child: CircularProgressIndicator(color: AppTheme.primaryEmerald)))
else if (state is AssetTradesError)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Text('Fehler: ${state.message}', style: TextStyle(color: AppTheme.accentRed)),
)
else if (state is AssetTradesLoaded) ...[
_buildTradeList(
'Aktive Trade-Signale & Positionen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'ACTIVE' || s == 'PENDING' || s == 'PROPOSED';
}).toList(),
),
const SizedBox(height: 20),
_buildTradeList(
'Historische Trades & KI-Bewertungen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'CLOSED' || s == 'REJECTED' || (s != 'ACTIVE' && s != 'PENDING' && s != 'PROPOSED');
}).toList(),
),
],
],
),
);
},
);
}
Widget _buildTradeList(String title, List<TradeModel> trades) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 10),
if (trades.isEmpty)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('Keine Trades in dieser Kategorie vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: trades.length,
itemBuilder: (context, index) {
final trade = trades[index];
return _buildRichTradeCard(trade);
},
),
],
);
}
Widget _buildRichTradeCard(TradeModel trade) {
final isin = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
final status = trade.status.toUpperCase();
final isBuy = side == 'BUY' || side == 'LONG';
final isActive = status == 'ACTIVE';
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
// AI Execution Plan N8N values
final entryZoneMin = trade.entryZoneMin;
final entryZoneMax = trade.entryZoneMax;
final entryPrice = trade.entryPrice;
final stopLoss = trade.stopLoss;
final takeProfit = trade.takeProfit;
final takeProfitTargets = trade.takeProfitTargets;
final crv = (takeProfit > 0 && stopLoss > 0 && entryPrice > 0) ? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2) : null;
final maxLeverage = trade.maxLeverage;
// Real User Execution Values
final actualEntry = trade.actualEntryPrice;
final posSize = trade.positionSize;
final levUsed = trade.leverageUsed;
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
final entryFee = trade.entryFee;
final exitFee = trade.exitFee;
// Rationale strings
final reasoning = trade.reasoning;
final techRationale = trade.technicalRationale;
final fundRationale = trade.fundamentalRationale;
final riskWarning = trade.riskWarning;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row: Side, Status, Instrument, Action Buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
StatusBadge(label: side, color: sideColor),
const SizedBox(width: 8),
StatusBadge(label: status, color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted)),
const SizedBox(width: 8),
if (trade.instrumentType.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6),
),
child: Text(trade.instrumentType, style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
Row(
children: [
if (isActive) ...[
ElevatedButton.icon(
onPressed: () => _showCloseTradeDialog(context, trade),
icon: const Icon(Icons.flag_outlined, size: 14),
label: const Text('Schließen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () => _showEditTradeExecutionDialog(context, trade, isActive: true),
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
padding: const EdgeInsets.all(8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
ElevatedButton.icon(
onPressed: () => _showEditTradeExecutionDialog(context, trade),
icon: const Icon(Icons.check_circle, size: 14),
label: const Text('Trade Annehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
],
),
],
),
const SizedBox(height: 12),
// Asset ID & Timeframe Subheader
Text('${trade.companyName.isNotEmpty ? trade.companyName : widget.symbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
const SizedBox(height: 12),
// AI Execution Targets Grid (Entry Zone, SL, TP, CRV, MaxLeverage)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Stop-Loss', '${_fmt(stopLoss)}', AppTheme.accentRed),
_buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '${_fmt(t)}').join(' / ') : '${_fmt(takeProfit)}', AppTheme.primaryEmerald),
],
),
if (crv != null || maxLeverage > 0) ...[
const Divider(color: Colors.white12, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
],
),
],
],
),
),
// Real User Execution Data Section (Actual Entry, Position Size, Leverage Used, Fees, Quantity)
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Tatsächl. Einstieg', '${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white),
_buildTradeStat('Investition', posSize > 0 ? '${_fmt(posSize)}' : 'N/A', Colors.white),
_buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald),
_buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70),
],
),
if (entryFee > 0 || exitFee > 0) ...[
const SizedBox(height: 6),
Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
],
),
),
],
// Closed Trade Outcome & Performance Section
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
const SizedBox(height: 12),
Builder(
builder: (context) {
final pnlVal = trade.calculatedPnlAbs;
final pnlPctVal = trade.calculatedPnlPct;
final isWin = pnlVal >= 0;
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
isWin ? Icons.trending_up : Icons.trending_down,
size: 16,
color: color,
),
const SizedBox(width: 6),
const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
],
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Ausstiegskurs', 'N/A', Colors.white),
_buildTradeStat(
'Realisierter PnL (€)',
'${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}',
color,
),
_buildTradeStat(
'Rendite (%)',
'${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%',
pnlPctVal >= 0 ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
],
),
],
),
);
},
),
],
// AI Rationale & Warnings
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
const SizedBox(height: 12),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
dense: true,
title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)),
children: [
if (reasoning.isNotEmpty) ...[
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
const SizedBox(height: 8),
],
if (techRationale.isNotEmpty) ...[
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
const SizedBox(height: 8),
],
if (fundRationale.isNotEmpty) ...[
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
const SizedBox(height: 8),
],
if (riskWarning.isNotEmpty)
_buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
],
),
],
],
),
),
);
}
Widget _buildTradeStat(String title, String val, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)),
],
);
}
Widget _buildRationaleBlock(String title, String text, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)),
const SizedBox(height: 2),
Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)),
],
);
}
String _fmt(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? n.toStringAsFixed(2) : val.toString();
}
}
@@ -0,0 +1,799 @@
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class CandleModel {
final DateTime time;
final double open;
final double high;
final double low;
final double close;
final double volume;
CandleModel({
required this.time,
required this.open,
required this.high,
required this.low,
required this.close,
required this.volume,
});
factory CandleModel.fromJson(Map<String, dynamic> json) {
return CandleModel(
time: DateTime.tryParse(json['timestamp'] ?? json['time'] ?? '') ?? DateTime.now(),
open: (json['open'] ?? 0).toDouble(),
high: (json['high'] ?? 0).toDouble(),
low: (json['low'] ?? 0).toDouble(),
close: (json['close'] ?? 0).toDouble(),
volume: (json['volume'] ?? 0).toDouble(),
);
}
}
class IndicatorModel {
final DateTime timestamp;
final double? ema20;
final double? sma50;
final double? sma200;
final double? supertrendUpper;
final double? supertrendLower;
final String? supertrendDirection;
IndicatorModel({
required this.timestamp,
this.ema20,
this.sma50,
this.sma200,
this.supertrendUpper,
this.supertrendLower,
this.supertrendDirection,
});
factory IndicatorModel.fromJson(Map<String, dynamic> json) {
return IndicatorModel(
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
ema20: json['ema20'] != null ? (json['ema20'] as num).toDouble() : null,
sma50: json['sma50'] != null ? (json['sma50'] as num).toDouble() : null,
sma200: json['sma200'] != null ? (json['sma200'] as num).toDouble() : null,
supertrendUpper: json['supertrendUpper'] != null ? (json['supertrendUpper'] as num).toDouble() : null,
supertrendLower: json['supertrendLower'] != null ? (json['supertrendLower'] as num).toDouble() : null,
supertrendDirection: json['supertrendDirection']?.toString(),
);
}
}
class PatternPoint {
final DateTime time;
final double price;
PatternPoint(this.time, this.price);
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble());
}
class ChartPatternModel {
final String type;
final List<PatternPoint> upperLine;
final List<PatternPoint> lowerLine;
ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine});
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
return ChartPatternModel(
type: json['type'] ?? '',
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
);
}
}
class StrategySignalModel {
final String type;
final DateTime timestamp;
final String direction;
final double price;
final String description;
StrategySignalModel({required this.type, required this.timestamp, required this.direction, required this.price, required this.description});
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
return StrategySignalModel(
type: json['type'] ?? '',
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
direction: json['direction'] ?? '',
price: (json['price'] as num).toDouble(),
description: json['description'] ?? '',
);
}
}
class CandlestickChart extends StatefulWidget {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
const CandlestickChart({
super.key,
required this.candles,
this.patterns = const [],
this.signals = const [],
this.indicators = const [],
this.showPatterns = true,
this.showSma50 = true,
this.showSma200 = true,
this.showEma = true,
this.showSignals = true,
this.showSupertrend = true,
});
@override
State<CandlestickChart> createState() => _CandlestickChartState();
}
class _CandlestickChartState extends State<CandlestickChart> {
double _scale = 1.0;
double _panOffset = 0.0;
Offset? _tapPosition;
CandleModel? _selectedCandle;
@override
Widget build(BuildContext context) {
if (widget.candles.isEmpty) {
return const Center(child: Text('No chart data'));
}
final theme = AppTheme.activePreset;
return LayoutBuilder(
builder: (context, constraints) {
final double baseWidth = 10.0;
final double spacing = 5.0;
final double totalCandleSpace = (baseWidth + spacing) * _scale;
final double totalContentWidth = (widget.candles.length + 15) * totalCandleSpace;
final double minOffset = constraints.maxWidth - totalContentWidth - 60.0;
final double maxOffset = 100.0;
_panOffset = _panOffset.clamp(minOffset < maxOffset ? minOffset : maxOffset, maxOffset);
return Listener(
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
setState(() {
final double zoomFactor = pointerSignal.scrollDelta.dy > 0 ? 0.9 : 1.1;
_scale = (_scale * zoomFactor).clamp(0.2, 5.0);
});
}
},
child: GestureDetector(
onScaleUpdate: (details) {
setState(() {
_scale = (_scale * details.scale).clamp(0.2, 5.0);
_panOffset += details.focalPointDelta.dx;
_panOffset = _panOffset.clamp(minOffset, maxOffset);
if (_tapPosition != null) {
_handleTap(Offset(_tapPosition!.dx + details.focalPointDelta.dx, _tapPosition!.dy), constraints.maxWidth);
}
});
},
onScaleEnd: (_) => setState(() {
_tapPosition = null;
_selectedCandle = null;
}),
onTapDown: (details) {
_handleTap(details.localPosition, constraints.maxWidth);
},
child: Stack(
children: [
ClipRect(
child: CustomPaint(
size: Size.infinite,
painter: _CandlePainter(
candles: widget.candles,
patterns: widget.patterns,
signals: widget.signals,
indicators: widget.indicators,
scale: _scale,
panOffset: _panOffset,
theme: theme,
showPatterns: widget.showPatterns,
showSma50: widget.showSma50,
showSma200: widget.showSma200,
showEma: widget.showEma,
showSignals: widget.showSignals,
showSupertrend: widget.showSupertrend,
tapPosition: _tapPosition,
),
),
),
if (_selectedCandle != null) _buildTooltip(theme),
// Floating Zoom & Pan Controls (Top-Left)
Positioned(
left: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: theme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.zoom_in, size: 18),
color: theme.primaryColor,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _scale = (_scale * 1.25).clamp(0.2, 5.0)),
tooltip: 'Zoom In',
),
IconButton(
icon: const Icon(Icons.zoom_out, size: 18),
color: theme.primaryColor,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _scale = (_scale * 0.8).clamp(0.2, 5.0)),
tooltip: 'Zoom Out',
),
IconButton(
icon: const Icon(Icons.center_focus_strong, size: 18),
color: theme.textMuted,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() {
_scale = 1.0;
_panOffset = 0.0;
}),
tooltip: 'Reset Zoom & Pan',
),
],
),
),
),
],
),
),
);
},
);
}
void _handleTap(Offset pos, double width) {
if (widget.candles.isEmpty) return;
// Right side is for axis, don't tap there
if (pos.dx > width - 60) return;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * _scale;
final double totalCandleSpace = candleWidth + (spacing * _scale);
// dx = (i * totalCandleSpace) + _panOffset;
// (dx - _panOffset) / totalCandleSpace = i;
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
if (index >= 0 && index < widget.candles.length) {
setState(() {
_tapPosition = pos;
_selectedCandle = widget.candles[index];
});
}
}
Widget _buildTooltip(ThemePreset theme) {
final candle = _selectedCandle!;
final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.day.toString().padLeft(2,'0')}";
return Positioned(
left: 10,
top: 10,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(dateStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
Text('O: ${candle.open.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('H: ${candle.high.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('L: ${candle.low.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('C: ${candle.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('Vol: ${candle.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
],
),
),
);
}
}
class _CandlePainter extends CustomPainter {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final double scale;
final double panOffset;
final ThemePreset theme;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
final Offset? tapPosition;
final double rightPadding = 60.0; // Space for price axis
final double bottomPadding = 20.0; // Space for X-axis labels
_CandlePainter({
required this.candles,
required this.patterns,
required this.signals,
required this.indicators,
required this.scale,
required this.panOffset,
required this.theme,
required this.showPatterns,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSignals,
required this.showSupertrend,
this.tapPosition,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
final double chartWidth = size.width - rightPadding;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
double maxPrice = 0;
double minPrice = double.infinity;
// Find min/max in view
int firstVisibleIndex = -1;
for (int i = 0; i < candles.length; i++) {
final dx = (i * totalCandleSpace) + panOffset;
if (dx + candleWidth > 0 && dx < chartWidth) {
if (firstVisibleIndex == -1) firstVisibleIndex = i;
final c = candles[i];
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (minPrice == double.infinity || maxPrice == 0) return;
// Add 10% padding to top/bottom
final range = maxPrice - minPrice;
maxPrice += range * 0.1;
minPrice -= range * 0.1;
final paddedRange = maxPrice - minPrice;
if (paddedRange <= 0) return;
final double chartHeight = size.height - bottomPadding;
final double volumeHeight = chartHeight * 0.15; // Bottom 15% for volume
final double candleAreaHeight = chartHeight - volumeHeight;
double maxVolume = 0;
for (int i = 0; i < candles.length; i++) {
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
}
if (maxVolume == 0) maxVolume = 1;
_drawGridAndAxis(canvas, size, chartWidth, candleAreaHeight, minPrice, maxPrice, paddedRange);
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
final ema20Path = Path();
final sma50Path = Path();
final sma200Path = Path();
final supertrendPath = Path();
bool firstEma20 = true;
bool firstSma50 = true;
bool firstSma200 = true;
bool firstSupertrend = true;
// Map DateTime to X for patterns and signals
double getXForTime(DateTime t) {
int bestIndex = 0;
int minDiff = 999999999;
for (int i = 0; i < candles.length; i++) {
final diff = candles[i].time.difference(t).inSeconds.abs();
if (diff < minDiff) {
minDiff = diff;
bestIndex = i;
}
}
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
}
double getYForPrice(double price) {
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
}
// Clip to chart area so we don't draw over the axis
canvas.save();
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final isBullish = candle.close >= candle.open;
final dx = (i * totalCandleSpace) + panOffset;
if (dx < -candleWidth || dx > chartWidth) continue; // Culling
final yHigh = getYForPrice(candle.high);
final yLow = getYForPrice(candle.low);
final yOpen = getYForPrice(candle.open);
final yClose = getYForPrice(candle.close);
// Draw Wick
canvas.drawLine(
Offset(dx + candleWidth / 2, yHigh),
Offset(dx + candleWidth / 2, yLow),
isBullish ? paintWickBullish : paintWickBearish,
);
// Draw Body
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
final bodyHeight = max(bottom - top, 1.0); // minimum 1px height
canvas.drawRect(
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
isBullish ? paintBullish : paintBearish,
);
// Draw Volume
final vHeight = (candle.volume / maxVolume) * volumeHeight;
final vTop = chartHeight - vHeight;
canvas.drawRect(
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
);
// Indicators mapping by time
if (indicators.isNotEmpty) {
final cx = dx + candleWidth / 2;
IndicatorModel? match;
for (var ind in indicators) {
if (ind.timestamp.isAtSameMomentAs(candle.time) || ind.timestamp.difference(candle.time).inHours.abs() < 12) {
match = ind;
break;
}
}
if (match != null) {
if (showEma && match.ema20 != null) {
final y = getYForPrice(match.ema20!);
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
else { ema20Path.lineTo(cx, y); }
}
if (showSma50 && match.sma50 != null) {
final y = getYForPrice(match.sma50!);
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
else { sma50Path.lineTo(cx, y); }
}
if (showSma200 && match.sma200 != null) {
final y = getYForPrice(match.sma200!);
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
else { sma200Path.lineTo(cx, y); }
}
if (showSupertrend) {
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
if (stVal != null) {
final y = getYForPrice(stVal);
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
else { supertrendPath.lineTo(cx, y); }
}
}
}
}
}
if (showEma && !firstEma20) {
canvas.drawPath(ema20Path, Paint()..color = theme.primaryColor..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma50 && !firstSma50) {
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma200 && !firstSma200) {
canvas.drawPath(sma200Path, Paint()..color = Colors.purpleAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showSupertrend && !firstSupertrend) {
canvas.drawPath(supertrendPath, Paint()..color = Colors.lightBlueAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showPatterns) {
_drawPatterns(canvas, getXForTime, getYForPrice);
_drawFutureProjectionZone(canvas, size, chartWidth, candleAreaHeight, getXForTime, getYForPrice);
}
if (showSignals) {
_drawSignals(canvas, getXForTime, getYForPrice);
}
if (tapPosition != null && tapPosition!.dx < chartWidth) {
_drawCrosshair(canvas, size, chartWidth, chartHeight);
}
canvas.restore(); // Restore clip
}
void _drawFutureProjectionZone(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double Function(DateTime) getX, double Function(double) getY) {
if (candles.isEmpty) return;
final lastCandle = candles.last;
final double lastX = getX(lastCandle.time);
if (lastX < chartWidth) {
// 1. Shaded background for Future Zone (No divider line)
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
final futureBgPaint = Paint()
..color = const Color(0xFF001F3F).withValues(alpha: 0.25)
..style = PaintingStyle.fill;
canvas.drawRect(futureRect, futureBgPaint);
// Label for Future Zone
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: 'PROGNOSE (MUSTER-SCHÄTZUNG)',
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
);
textPainter.layout();
textPainter.paint(canvas, Offset(lastX + 8, 8));
// 2. Projected Ghost Candles & Target Line for active patterns
for (var pattern in patterns) {
if (pattern.lowerLine.isNotEmpty || pattern.upperLine.isNotEmpty) {
final targetPrice = pattern.lowerLine.isNotEmpty ? pattern.lowerLine.last.price : (pattern.upperLine.isNotEmpty ? pattern.upperLine.last.price : 0);
if (targetPrice > 0) {
final targetY = getY(targetPrice.toDouble());
final int numSteps = 10;
final double stepWidth = (chartWidth - lastX - 30) / numSteps;
if (stepWidth <= 0) continue;
final isBullish = targetPrice >= lastCandle.close;
final projColor = isBullish ? Colors.greenAccent : Colors.redAccent;
double currX = lastX;
double currPrice = lastCandle.close;
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
for (int k = 1; k <= numSteps; k++) {
final nextX = lastX + k * stepWidth;
final waveNoise = sin(k * 0.8) * (priceDeltaPerStep.abs() * 0.3);
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.2;
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.2;
final yOpen = getY(currPrice);
final yClose = getY(nextPrice);
final yHigh = getY(highPrice);
final yLow = getY(lowPrice);
final cWidth = max(stepWidth * 0.6, 3.0);
final cLeft = nextX - cWidth / 2;
final isStepBullish = nextPrice >= currPrice;
final stepColor = isStepBullish ? Colors.greenAccent : Colors.redAccent;
// Draw Ghost Candle Wick
canvas.drawLine(
Offset(nextX, yHigh),
Offset(nextX, yLow),
Paint()..color = stepColor.withValues(alpha: 0.4)..strokeWidth = 1.0,
);
// Draw Ghost Candle Body
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
canvas.drawRect(
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.0)),
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
);
currX = nextX;
currPrice = nextPrice;
}
// Target Price Badge at final step
final targetX = currX;
final targetBadgePainter = TextPainter(
text: TextSpan(
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
targetBadgePainter.layout();
final badgeRect = RRect.fromLTRBR(
targetX - targetBadgePainter.width / 2,
targetY - targetBadgePainter.height / 2 - 2,
targetX + targetBadgePainter.width / 2,
targetY + targetBadgePainter.height / 2 + 2,
const Radius.circular(6),
);
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.9));
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
}
}
}
}
}
void _drawGridAndAxis(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double minPrice, double maxPrice, double range) {
final gridPaint = Paint()
..color = theme.glassBorder
..strokeWidth = 1;
final textPainter = TextPainter(textDirection: TextDirection.ltr);
// Y Axis
final int gridLines = 5;
for (int i = 0; i <= gridLines; i++) {
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
final price = minPrice + (i / gridLines) * range;
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
textPainter.text = TextSpan(
text: price.toStringAsFixed(2),
style: TextStyle(color: theme.textMuted, fontSize: 11),
);
textPainter.layout();
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
}
// X Axis
if (candles.isEmpty) return;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
final int xSteps = (chartWidth / 80).floor(); // label every 80px
if (xSteps <= 0) return;
for (int i = 1; i < xSteps; i++) {
double x = i * (chartWidth / xSteps);
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
if (candleIndex >= 0 && candleIndex < candles.length) {
final t = candles[candleIndex].time;
textPainter.text = TextSpan(
text: "${t.month.toString().padLeft(2,'0')}-${t.day.toString().padLeft(2,'0')}",
style: TextStyle(color: theme.textMuted, fontSize: 10),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
}
}
}
void _drawPatterns(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
final paint = Paint()
..color = Colors.orangeAccent
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
for (var pattern in patterns) {
void drawLine(List<PatternPoint> points) {
if (points.length < 2) return;
final path = Path();
path.moveTo(getX(points[0].time), getY(points[0].price));
for (int i = 1; i < points.length; i++) {
path.lineTo(getX(points[i].time), getY(points[i].price));
}
canvas.drawPath(path, paint);
}
drawLine(pattern.upperLine);
drawLine(pattern.lowerLine);
}
}
void _drawSignals(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
for (var signal in signals) {
final x = getX(signal.timestamp);
final y = getY(signal.price);
final isBuy = signal.direction.toUpperCase() == 'BUY';
final isSell = signal.direction.toUpperCase() == 'SELL';
if (!isBuy && !isSell) continue;
final color = isBuy ? theme.primaryColor : theme.accentRed;
final label = isBuy ? '▲ BUY' : '▼ SELL';
// Draw Pill Badge for Signal
final textPainter = TextPainter(
text: TextSpan(
text: label,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
final badgeWidth = textPainter.width + 12;
final badgeHeight = textPainter.height + 6;
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
final badgeRect = RRect.fromLTRBR(
x - badgeWidth / 2,
badgeY,
x + badgeWidth / 2,
badgeY + badgeHeight,
const Radius.circular(10),
);
// Pill Background
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
// Pointer Line to price point
canvas.drawLine(
Offset(x, y),
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
Paint()..color = color..strokeWidth = 1.5,
);
// Text paint
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
}
}
void _drawCrosshair(Canvas canvas, Size size, double chartWidth, double chartHeight) {
final paint = Paint()
..color = theme.textMuted.withValues(alpha: 0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
// Vertical
canvas.drawLine(Offset(tapPosition!.dx, 0), Offset(tapPosition!.dx, chartHeight), paint);
// Horizontal
if (tapPosition!.dy <= chartHeight) {
canvas.drawLine(Offset(0, tapPosition!.dy), Offset(chartWidth, tapPosition!.dy), paint);
}
}
@override
bool shouldRepaint(covariant _CandlePainter oldDelegate) {
return oldDelegate.scale != scale ||
oldDelegate.panOffset != panOffset ||
oldDelegate.candles != candles ||
oldDelegate.patterns != patterns ||
oldDelegate.signals != signals ||
oldDelegate.indicators != indicators ||
oldDelegate.tapPosition != tapPosition;
}
}
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/asset_logo_widget.dart';
import '../../../../shared/widgets/favorite_star_button.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../models/asset_model.dart';
class AssetHeroHeader extends StatelessWidget {
final String symbol;
final void Function(String exchange, String ticker)? onExchangeChanged;
final VoidCallback? onForceRefresh;
final String? selectedExchange;
const AssetHeroHeader({
super.key,
required this.symbol,
this.onExchangeChanged,
this.onForceRefresh,
this.selectedExchange,
});
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocBuilder<AssetHeaderBloc, AssetHeaderState>(
builder: (context, state) {
String name = symbol;
double? price;
String currency = 'EUR';
String currentExchange = selectedExchange ?? 'XETRA';
List<AssetTickerOption> tickerOptions = [
AssetTickerOption(ticker: 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: 0.0)
];
AssetModel? asset;
if (state is AssetHeaderLoaded) {
asset = state.data;
} else if (state is AssetHeaderLoading) {
asset = state.previousData;
}
if (asset != null) {
name = asset.name.isNotEmpty ? asset.name : symbol;
currency = asset.currency.isNotEmpty ? asset.currency : 'EUR';
price = asset.currentPrice;
currentExchange = selectedExchange ?? asset.exchange;
if (asset.tickers.isNotEmpty) {
tickerOptions = asset.tickers;
}
}
final selectedOption = tickerOptions.firstWhere(
(t) => t.exchange == currentExchange,
orElse: () => tickerOptions.first,
);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
decoration: BoxDecoration(
color: theme.cardSurface,
border: Border(bottom: BorderSide(color: theme.glassBorder)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
if (Navigator.canPop(context)) ...[
IconButton(
tooltip: 'Zurück',
icon: Icon(Icons.arrow_back, color: theme.textPrimary),
onPressed: () => Navigator.maybePop(context),
),
const SizedBox(width: 8),
],
AssetLogoWidget(symbolOrName: symbol, size: 48),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
name,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: theme.textPrimary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 2),
SelectableText(
symbol,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
letterSpacing: 1.0,
),
),
],
),
),
],
),
),
Row(
children: [
IconButton(
tooltip: 'Force Refresh Data',
icon: Icon(Icons.refresh, color: theme.primaryColor),
onPressed: onForceRefresh,
),
const SizedBox(width: 8),
FavoriteStarButton(symbol: symbol, identifier: symbol, name: name),
],
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'LIVE PRICE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
letterSpacing: 1.5,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SelectableText(
price != null && price > 0 ? price.toStringAsFixed(2) : '---',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: theme.textPrimary,
),
),
const SizedBox(width: 6),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
selectedOption.tradingCurrency.isNotEmpty ? selectedOption.tradingCurrency : currency,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
),
),
),
],
),
],
),
// Interactive Ticker & Exchange Selector Dropdown
PopupMenuButton<String>(
initialValue: selectedOption.exchange,
tooltip: 'Select Exchange & Ticker',
onSelected: (newExchange) {
if (onExchangeChanged != null) {
final opt = tickerOptions.firstWhere(
(t) => t.exchange == newExchange,
orElse: () => tickerOptions.first,
);
onExchangeChanged!(newExchange, opt.ticker);
}
},
itemBuilder: (context) {
return tickerOptions.map((opt) {
final ex = opt.exchange;
final tick = opt.ticker;
final label = '$tick ($ex)';
final isSelected = ex == currentExchange;
return PopupMenuItem<String>(
value: ex,
child: Row(
children: [
Icon(
Icons.business,
size: 16,
color: isSelected ? theme.primaryColor : theme.textMuted,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? theme.primaryColor : theme.textPrimary,
),
),
],
),
);
}).toList();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: theme.accentColor.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.business, size: 14, color: theme.accentColor),
const SizedBox(width: 6),
Text(
'${selectedOption.ticker} (${selectedOption.exchange})',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: theme.accentColor,
),
),
const SizedBox(width: 4),
Icon(Icons.arrow_drop_down, size: 16, color: theme.accentColor),
],
),
),
),
],
),
],
),
);
},
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
/// Modal dialog explaining key financial metric formulas and trading significance.
class MetricExplanationModal extends StatelessWidget {
final String title;
final String formula;
final String description;
final String tradingSignificance;
const MetricExplanationModal({
super.key,
required this.title,
required this.formula,
required this.description,
required this.tradingSignificance,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Kennzahl: $title'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Formel:', style: TextStyle(fontWeight: FontWeight.bold)),
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8),
),
child: Text(formula, style: const TextStyle(fontFamily: 'monospace', color: Colors.cyanAccent)),
),
const SizedBox(height: 12),
const Text('Erklärung:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(description, style: const TextStyle(fontSize: 13)),
const SizedBox(height: 12),
const Text('Bedeutung für Trading & Bewertung:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(tradingSignificance, style: const TextStyle(fontSize: 13, color: Colors.white70)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Schließen')),
],
);
}
}
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class CandleData {
final DateTime time;
final double open;
final double high;
final double low;
final double close;
CandleData({
required this.time,
required this.open,
required this.high,
required this.low,
required this.close,
});
factory CandleData.fromJson(Map<String, dynamic> json) {
return CandleData(
time: json['timestamp'] != null ? DateTime.parse(json['timestamp'].toString()) : DateTime.now(),
open: (json['open'] as num? ?? 0.0).toDouble(),
high: (json['high'] as num? ?? 0.0).toDouble(),
low: (json['low'] as num? ?? 0.0).toDouble(),
close: (json['close'] as num? ?? 0.0).toDouble(),
);
}
}
class CandleChartWidget extends StatelessWidget {
final List<CandleData> candles;
final double? supportLevel;
final double? resistanceLevel;
const CandleChartWidget({
super.key,
this.candles = const [],
this.supportLevel,
this.resistanceLevel,
});
@override
Widget build(BuildContext context) {
if (candles.isEmpty) {
return Container(
color: AppTheme.cardSurface,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.show_chart, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
Text(
'Keine Candlestick-Daten verfgbar',
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
),
],
),
),
);
}
return Container(
color: Colors.black.withValues(alpha: 0.6),
padding: const EdgeInsets.all(12),
child: CustomPaint(
painter: _CandlePainter(
candles: candles,
supportLevel: supportLevel,
resistanceLevel: resistanceLevel,
),
child: Container(),
),
);
}
}
class _CandlePainter extends CustomPainter {
final List<CandleData> candles;
final double? supportLevel;
final double? resistanceLevel;
_CandlePainter({
required this.candles,
this.supportLevel,
this.resistanceLevel,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
double minPrice = candles.first.low;
double maxPrice = candles.first.high;
for (var c in candles) {
if (c.low < minPrice) minPrice = c.low;
if (c.high > maxPrice) maxPrice = c.high;
}
if (supportLevel != null && supportLevel! < minPrice) minPrice = supportLevel!;
if (resistanceLevel != null && resistanceLevel! > maxPrice) maxPrice = resistanceLevel!;
final priceRange = (maxPrice - minPrice) == 0 ? 1.0 : (maxPrice - minPrice);
final padding = size.height * 0.05;
final usableHeight = size.height - (padding * 2);
double getY(double price) {
final normalized = (price - minPrice) / priceRange;
return size.height - padding - (normalized * usableHeight);
}
// Gridlines
final gridPaint = Paint()
..color = Colors.white10
..strokeWidth = 1;
for (int i = 1; i <= 4; i++) {
final y = size.height * (i / 5);
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
// Support Line
if (supportLevel != null) {
final supPaint = Paint()
..color = AppTheme.primaryEmerald.withValues(alpha: 0.6)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final y = getY(supportLevel!);
canvas.drawLine(Offset(0, y), Offset(size.width, y), supPaint);
}
// Resistance Line
if (resistanceLevel != null) {
final resPaint = Paint()
..color = AppTheme.accentRed.withValues(alpha: 0.6)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final y = getY(resistanceLevel!);
canvas.drawLine(Offset(0, y), Offset(size.width, y), resPaint);
}
// Candlesticks
final candleWidth = (size.width / candles.length) * 0.7;
final candleSpacing = size.width / candles.length;
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final x = (i * candleSpacing) + (candleSpacing / 2);
final isBullish = candle.close >= candle.open;
final candleColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
final wickPaint = Paint()
..color = candleColor
..strokeWidth = 1.5;
final highY = getY(candle.high);
final lowY = getY(candle.low);
canvas.drawLine(Offset(x, highY), Offset(x, lowY), wickPaint);
final openY = getY(candle.open);
final closeY = getY(candle.close);
final topY = openY < closeY ? openY : closeY;
final bodyHeight = (openY - closeY).abs();
final bodyPaint = Paint()
..color = candleColor
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(
x - (candleWidth / 2),
topY,
candleWidth,
bodyHeight < 1 ? 1 : bodyHeight,
),
bodyPaint,
);
}
}
@override
bool shouldRepaint(covariant _CandlePainter oldDelegate) => true;
}