feat(App): update Finlytic Flutter app UI and blocs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user