refactor: save current workspace state including FinlyticAnalyzer fixes, FinlyticApp trade route alignment, and DTO audit documentation
This commit is contained in:
@@ -1,98 +0,0 @@
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// Asset utility helpers to map ISIN codes, symbols, and company names to logos and details.
|
||||
class AssetUtils {
|
||||
static final Map<String, String> _isinToNameMap = {
|
||||
'US0378331005': 'Apple Inc.',
|
||||
'US5949181045': 'Microsoft Corp.',
|
||||
'US0231351067': 'Amazon.com Inc.',
|
||||
'US67066G1040': 'NVIDIA Corp.',
|
||||
'US88160R1014': 'Tesla Inc.',
|
||||
'US02079K3059': 'Alphabet Inc.',
|
||||
'US30303M1027': 'Meta Platforms',
|
||||
'DE0007164600': 'SAP SE',
|
||||
'DE0007236101': 'Siemens AG',
|
||||
'DE0008469008': 'Allianz SE',
|
||||
'FR0004125920': 'Amundi',
|
||||
};
|
||||
|
||||
static final Map<String, String> _nameToIsinMap = {
|
||||
'APPLE INC.': 'US0378331005',
|
||||
'APPLE': 'US0378331005',
|
||||
'MICROSOFT CORP.': 'US5949181045',
|
||||
'MICROSOFT': 'US5949181045',
|
||||
'AMAZON.COM INC.': 'US0231351067',
|
||||
'AMAZON': 'US0231351067',
|
||||
'NVIDIA CORP.': 'US67066G1040',
|
||||
'NVIDIA': 'US67066G1040',
|
||||
'TESLA INC.': 'US88160R1014',
|
||||
'TESLA': 'US88160R1014',
|
||||
'ALPHABET INC.': 'US02079K3059',
|
||||
'ALPHABET': 'US02079K3059',
|
||||
'META PLATFORMS': 'US30303M1027',
|
||||
'META': 'US30303M1027',
|
||||
'SAP SE': 'DE0007164600',
|
||||
'SAP': 'DE0007164600',
|
||||
'SIEMENS AG': 'DE0007236101',
|
||||
'SIEMENS': 'DE0007236101',
|
||||
'ALLIANZ SE': 'DE0008469008',
|
||||
'ALLIANZ': 'DE0008469008',
|
||||
'AMUNDI': 'FR0004125920',
|
||||
};
|
||||
|
||||
static final Map<String, String> _imageMap = {};
|
||||
|
||||
/// Registers an ISIN, Name, and optional Logo Image URL.
|
||||
static void registerAsset(String isin, String name, [String? imageUrl]) {
|
||||
final cleanIsin = isin.trim().toUpperCase();
|
||||
final cleanName = name.trim();
|
||||
if (cleanIsin.isNotEmpty && cleanName.isNotEmpty) {
|
||||
_isinToNameMap[cleanIsin] = cleanName;
|
||||
_nameToIsinMap[cleanName.toUpperCase()] = cleanIsin;
|
||||
}
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
final resolved = resolveUrl(imageUrl);
|
||||
if (cleanIsin.isNotEmpty) _imageMap[cleanIsin] = resolved;
|
||||
if (cleanName.isNotEmpty) _imageMap[cleanName.toUpperCase()] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves relative logo URLs (/api/logo/...) to complete backend endpoints.
|
||||
static String resolveUrl(String url) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return '${ApiClient.baseUrl}$url';
|
||||
}
|
||||
return '${ApiClient.baseUrl}/$url';
|
||||
}
|
||||
|
||||
/// Resolves an ISIN or symbol to readable asset name.
|
||||
static String getAssetName(String isinOrSymbol) {
|
||||
final key = isinOrSymbol.trim().toUpperCase();
|
||||
if (_isinToNameMap.containsKey(key)) {
|
||||
return _isinToNameMap[key]!;
|
||||
}
|
||||
return isinOrSymbol;
|
||||
}
|
||||
|
||||
/// Resolves an asset name or symbol to ISIN.
|
||||
static String? getIsin(String nameOrSymbol) {
|
||||
final key = nameOrSymbol.trim().toUpperCase();
|
||||
if (_isinToNameMap.containsKey(key)) return key;
|
||||
return _nameToIsinMap[key];
|
||||
}
|
||||
|
||||
/// Returns official local backend logo URL for given symbol/name/ISIN.
|
||||
static String? getLogoUrl(String symbolOrName) {
|
||||
final key = symbolOrName.trim().toUpperCase();
|
||||
if (_imageMap.containsKey(key)) return resolveUrl(_imageMap[key]!);
|
||||
|
||||
final isin = getIsin(key) ?? (key.length == 12 ? key : null);
|
||||
if (isin != null && isin.length == 12) {
|
||||
return '${ApiClient.baseUrl}/api/logo/$isin';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../network/api_client.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/asset_utils.dart';
|
||||
|
||||
/// Reusable performance-optimized Asset Logo Widget supporting SVG, PNG, gradient fallbacks, and Hero transitions.
|
||||
class AssetLogoWidget extends StatelessWidget {
|
||||
@@ -15,24 +15,29 @@ class AssetLogoWidget extends StatelessWidget {
|
||||
const AssetLogoWidget({
|
||||
super.key,
|
||||
required this.symbolOrName,
|
||||
this.imageUrl,
|
||||
required this.imageUrl,
|
||||
this.size = 32,
|
||||
this.enableHero = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rawUrl = imageUrl ?? AssetUtils.getLogoUrl(symbolOrName);
|
||||
final logoUrl = rawUrl != null && rawUrl.isNotEmpty ? AssetUtils.resolveUrl(rawUrl) : null;
|
||||
// Resolve relative URLs (e.g. /api/v1/logo/...) to include host and port (e.g. http://localhost:5000)
|
||||
String? resolveUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return null;
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
return url.startsWith('/') ? '${ApiClient.baseUrl}$url' : '${ApiClient.baseUrl}/$url';
|
||||
}
|
||||
|
||||
final image = resolveUrl(imageUrl);
|
||||
final initial = symbolOrName.isNotEmpty ? symbolOrName[0].toUpperCase() : 'A';
|
||||
final colors = _getGradientColors(initial);
|
||||
|
||||
Widget content;
|
||||
|
||||
if (logoUrl != null && logoUrl.isNotEmpty && !_failedUrls.contains(logoUrl)) {
|
||||
final isSvg = logoUrl.toLowerCase().endsWith('.svg') ||
|
||||
logoUrl.contains('traderepublic.com') ||
|
||||
logoUrl.contains('/api/logo/');
|
||||
if (image != null && image.isNotEmpty && !_failedUrls.contains(image)) {
|
||||
final isSvg = image.toLowerCase().endsWith('.svg') ||
|
||||
image.contains('/api/v1/logo/');
|
||||
|
||||
content = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(size * 0.3),
|
||||
@@ -50,26 +55,26 @@ class AssetLogoWidget extends StatelessWidget {
|
||||
padding: EdgeInsets.all(size * 0.1),
|
||||
child: isSvg
|
||||
? SvgPicture.network(
|
||||
logoUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
placeholderBuilder: (context) => _buildFallback(initial, colors),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
_failedUrls.add(logoUrl);
|
||||
return _buildFallback(initial, colors);
|
||||
},
|
||||
)
|
||||
image,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
placeholderBuilder: (context) => _buildFallback(initial, colors),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
_failedUrls.add(image);
|
||||
return _buildFallback(initial, colors);
|
||||
},
|
||||
)
|
||||
: Image.network(
|
||||
logoUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
_failedUrls.add(logoUrl);
|
||||
return _buildFallback(initial, colors);
|
||||
},
|
||||
),
|
||||
image,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
_failedUrls.add(image);
|
||||
return _buildFallback(initial, colors);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -78,7 +83,7 @@ class AssetLogoWidget extends StatelessWidget {
|
||||
|
||||
if (enableHero && symbolOrName.isNotEmpty) {
|
||||
return Hero(
|
||||
tag: 'asset_logo_$symbolOrName',
|
||||
tag: 'asset_logo_${symbolOrName}_$size',
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
@@ -127,4 +132,4 @@ class AssetLogoWidget extends StatelessWidget {
|
||||
return [AppTheme.activePreset.accentColor, const Color(0xFF3B82F6)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'asset_header_event.dart';
|
||||
import 'asset_header_state.dart';
|
||||
import '../../repositories/asset_repository.dart';
|
||||
import '../../models/asset_model.dart';
|
||||
|
||||
class AssetHeaderBloc extends Bloc<AssetHeaderEvent, AssetHeaderState> {
|
||||
final AssetRepository repository;
|
||||
@@ -10,8 +11,28 @@ class AssetHeaderBloc extends Bloc<AssetHeaderEvent, AssetHeaderState> {
|
||||
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));
|
||||
final fundamentals = await repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker);
|
||||
if (fundamentals != null) {
|
||||
final assetModel = AssetModel(
|
||||
isin: fundamentals.isin,
|
||||
symbol: fundamentals.primaryTicker.isNotEmpty ? fundamentals.primaryTicker : fundamentals.isin,
|
||||
name: fundamentals.companyName,
|
||||
currentPrice: fundamentals.currentPrice,
|
||||
currency: fundamentals.tradingCurrency ?? 'EUR',
|
||||
exchange: fundamentals.exchange ?? 'XETRA',
|
||||
exchanges: [], // Can be populated if needed
|
||||
tickers: fundamentals.availableTickers.map((t) => AssetTickerOption(
|
||||
ticker: t.ticker,
|
||||
exchange: t.exchange ?? 'Unknown',
|
||||
tradingCurrency: t.tradingCurrency ?? fundamentals.tradingCurrency ?? 'EUR',
|
||||
currentPrice: t.currentPrice,
|
||||
)).toList(),
|
||||
image: '/api/v1/logo/${fundamentals.isin}',
|
||||
);
|
||||
emit(AssetHeaderLoaded(assetModel));
|
||||
} else {
|
||||
emit(AssetHeaderError('Failed to load asset header data'));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(AssetHeaderError(e.toString()));
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ class FundamentalDataModel extends Equatable {
|
||||
final List<CompanyExecutiveModel> executives;
|
||||
final List<FinancialStatementModel> financialStatements;
|
||||
final List<ForwardEstimateModel> estimates;
|
||||
final List<TickerModel> availableTickers;
|
||||
|
||||
const FundamentalDataModel({
|
||||
required this.isin,
|
||||
@@ -111,6 +112,7 @@ class FundamentalDataModel extends Equatable {
|
||||
required this.executives,
|
||||
required this.financialStatements,
|
||||
required this.estimates,
|
||||
this.availableTickers = const [],
|
||||
});
|
||||
|
||||
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
|
||||
@@ -187,6 +189,10 @@ class FundamentalDataModel extends Equatable {
|
||||
?.map((e) => ForwardEstimateModel.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
availableTickers: (json['availableTickers'] as List?)
|
||||
?.map((e) => TickerModel.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,6 +248,7 @@ class FundamentalDataModel extends Equatable {
|
||||
'executives': executives.map((e) => e.toJson()).toList(),
|
||||
'financialStatements': financialStatements.map((e) => e.toJson()).toList(),
|
||||
'estimates': estimates.map((e) => e.toJson()).toList(),
|
||||
'availableTickers': availableTickers.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -297,6 +304,7 @@ class FundamentalDataModel extends Equatable {
|
||||
executives,
|
||||
financialStatements,
|
||||
estimates,
|
||||
availableTickers,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -526,3 +534,38 @@ class ForwardEstimateModel extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
|
||||
}
|
||||
|
||||
class TickerModel extends Equatable {
|
||||
final String ticker;
|
||||
final String? exchange;
|
||||
final String? tradingCurrency;
|
||||
final double currentPrice;
|
||||
|
||||
const TickerModel({
|
||||
required this.ticker,
|
||||
this.exchange,
|
||||
this.tradingCurrency,
|
||||
required this.currentPrice,
|
||||
});
|
||||
|
||||
factory TickerModel.fromJson(Map<String, dynamic> json) {
|
||||
return TickerModel(
|
||||
ticker: json['ticker']?.toString() ?? '',
|
||||
exchange: json['exchange']?.toString(),
|
||||
tradingCurrency: json['tradingCurrency']?.toString(),
|
||||
currentPrice: json['currentPrice'] != null ? double.tryParse(json['currentPrice'].toString()) ?? 0.0 : 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'ticker': ticker,
|
||||
'exchange': exchange,
|
||||
'tradingCurrency': tradingCurrency,
|
||||
'currentPrice': currentPrice,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
|
||||
}
|
||||
|
||||
@@ -118,6 +118,36 @@ class StrategySignalModel extends Equatable {
|
||||
List<Object?> get props => [title, date, price, type];
|
||||
}
|
||||
|
||||
class PatternPoint extends Equatable {
|
||||
final DateTime time;
|
||||
final double price;
|
||||
|
||||
const PatternPoint(this.time, this.price);
|
||||
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble());
|
||||
|
||||
@override
|
||||
List<Object?> get props => [time, price];
|
||||
}
|
||||
|
||||
class ChartPatternModel extends Equatable {
|
||||
final String type;
|
||||
final List<PatternPoint> upperLine;
|
||||
final List<PatternPoint> lowerLine;
|
||||
|
||||
const ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine});
|
||||
|
||||
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
|
||||
return ChartPatternModel(
|
||||
type: json['type']?.toString() ?? 'Pattern',
|
||||
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
|
||||
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [type, upperLine, lowerLine];
|
||||
}
|
||||
|
||||
class TechnicalAnalysisModel extends Equatable {
|
||||
final String symbol;
|
||||
final String trend;
|
||||
@@ -132,7 +162,7 @@ class TechnicalAnalysisModel extends Equatable {
|
||||
final double? stopLossAtr;
|
||||
final List<CandleModel> candles;
|
||||
final List<IndicatorModel> indicators;
|
||||
final List<String> patterns;
|
||||
final List<ChartPatternModel> patterns;
|
||||
final List<StrategySignalModel> signals;
|
||||
|
||||
const TechnicalAnalysisModel({
|
||||
@@ -164,20 +194,33 @@ class TechnicalAnalysisModel extends Equatable {
|
||||
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();
|
||||
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
|
||||
|
||||
|
||||
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
|
||||
final regime = json['marketRegime'] as Map<String, dynamic>?;
|
||||
|
||||
String parsedTrend = lastInd?.supertrendDirection ?? 'Neutral';
|
||||
if (parsedTrend.toUpperCase() == 'BUY') parsedTrend = 'Bullisch ▲';
|
||||
if (parsedTrend.toUpperCase() == 'SELL') parsedTrend = 'Bearisch ▼';
|
||||
|
||||
String parsedSignal = 'HOLD';
|
||||
if (signalsList.isNotEmpty) {
|
||||
parsedSignal = signalsList.last.type.toUpperCase();
|
||||
}
|
||||
|
||||
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(),
|
||||
trend: parsedTrend,
|
||||
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
||||
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
||||
overallSignal: parsedSignal,
|
||||
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
|
||||
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
|
||||
vix: (regime?['vixValue'] as num?)?.toDouble() ?? 16.5,
|
||||
sp500Trend: regime?['marketTrend']?.toString() ?? 'Bullish',
|
||||
dxy: (regime?['dxyValue'] as num?)?.toDouble() ?? 104.2,
|
||||
stopLossAtr: lastInd?.recommendedStopLoss,
|
||||
candles: candlesList,
|
||||
indicators: indicatorsList,
|
||||
patterns: patternsList,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
@@ -11,56 +11,9 @@ class AssetRepository {
|
||||
|
||||
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';
|
||||
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
|
||||
if (ticker != null && ticker.isNotEmpty) {
|
||||
url += '&ticker=$ticker';
|
||||
}
|
||||
@@ -76,7 +29,7 @@ class AssetRepository {
|
||||
|
||||
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
|
||||
try {
|
||||
String url = '/api/v1/ta/$isin?forceRefresh=$forceRefresh';
|
||||
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
|
||||
if (ticker != null && ticker.isNotEmpty) {
|
||||
url += '&ticker=$ticker';
|
||||
}
|
||||
@@ -92,7 +45,7 @@ class AssetRepository {
|
||||
|
||||
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
||||
try {
|
||||
String url = '/api/v1/trades?isin=$isin';
|
||||
String url = '/api/v1/user/trades?isin=$isin';
|
||||
if (status != null) url += '&status=$status';
|
||||
final res = await apiClient.get(url);
|
||||
if (res.statusCode == 200 && res.data != null) {
|
||||
|
||||
@@ -8,7 +8,10 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'action': 'Kauf-Order / Breakout-Trade beim Ausbruch über den horizontalen Widerstand.',
|
||||
'reliability': 'Hoch',
|
||||
'target': 'Höhe des Dreiecks an der Basis, addiert zum Ausbruchsniveau.',
|
||||
'stop_loss': 'Knapp unter der unteren (steigenden) Trendlinie.',
|
||||
},
|
||||
'DESCENDING_TRIANGLE': {
|
||||
'title': 'Fallendes Dreieck (Descending Triangle)',
|
||||
@@ -16,13 +19,19 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'reliability': 'Hoch',
|
||||
'target': 'Höhe des Dreiecks an der Basis, subtrahiert vom Ausbruchsniveau.',
|
||||
'stop_loss': 'Knapp über der oberen (fallenden) Trendlinie.',
|
||||
},
|
||||
'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.',
|
||||
'action': 'Verkauf/Short-Position beim Bruch der Nackenlinie.',
|
||||
'reliability': 'Sehr Hoch',
|
||||
'target': 'Distanz zwischen Kopf und Nackenlinie, vom Ausbruchspunkt der Nackenlinie nach unten projiziert.',
|
||||
'stop_loss': 'Knapp über der rechten Schulter.',
|
||||
},
|
||||
'INVERSE_HEAD_AND_SHOULDERS': {
|
||||
'title': 'Umgekehrte Kopf-Schulter-Formation',
|
||||
@@ -30,6 +39,9 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'reliability': 'Sehr Hoch',
|
||||
'target': 'Distanz zwischen Kopf (tiefster Punkt) und Nackenlinie, vom Ausbruchspunkt nach oben projiziert.',
|
||||
'stop_loss': 'Knapp unter der rechten Schulter.',
|
||||
},
|
||||
'BULL_FLAG': {
|
||||
'title': 'Bullische Flagge (Bull Flag)',
|
||||
@@ -37,6 +49,9 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'reliability': 'Hoch',
|
||||
'target': 'Länge des vorherigen Aufwärtstrends (Fahnenstange), angesetzt am Ausbruchspunkt der Flagge.',
|
||||
'stop_loss': 'Unterhalb des unteren Randes der Flagge.',
|
||||
},
|
||||
'BEAR_FLAG': {
|
||||
'title': 'Bärische Flagge (Bear Flag)',
|
||||
@@ -44,6 +59,9 @@ class PatternExplanations {
|
||||
'description': 'Kurze Aufwärtskonsolidierung in einem steilen Abwärtstrend.',
|
||||
'significance': 'Signalisiert eine Fortsetzung des steilen Abverkaufs.',
|
||||
'action': 'Short-Position bei Durchbrechen der unteren Flaggenkante.',
|
||||
'reliability': 'Hoch',
|
||||
'target': 'Länge des vorherigen Abwärtstrends (Fahnenstange), angesetzt am Ausbruchspunkt der Flagge.',
|
||||
'stop_loss': 'Oberhalb des oberen Randes der Flagge.',
|
||||
},
|
||||
'DOUBLE_BOTTOM': {
|
||||
'title': 'Doppelboden (W-Formation)',
|
||||
@@ -51,6 +69,9 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'reliability': 'Mittel bis Hoch',
|
||||
'target': 'Distanz zwischen dem Tief und dem Zwischenhoch, auf das Zwischenhoch addiert.',
|
||||
'stop_loss': 'Knapp unter den beiden Tiefpunkten.',
|
||||
},
|
||||
'DOUBLE_TOP': {
|
||||
'title': 'Doppeltopp (M-Formation)',
|
||||
@@ -58,6 +79,9 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'reliability': 'Mittel bis Hoch',
|
||||
'target': 'Distanz zwischen dem Hoch und dem Zwischentief, vom Zwischentief subtrahiert.',
|
||||
'stop_loss': 'Knapp über den beiden Höchstständen.',
|
||||
},
|
||||
'CHANNEL': {
|
||||
'title': 'Trendkanal (Trading Channel)',
|
||||
@@ -65,6 +89,9 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'reliability': 'Mittel',
|
||||
'target': 'Die gegenüberliegende Kanallinie (beim Swing-Trading) oder die Kanalbreite (beim Ausbruch).',
|
||||
'stop_loss': 'Außerhalb des Kanals auf der entgegengesetzten Seite des Einstiegs.',
|
||||
},
|
||||
'SUPPORT_RESISTANCE': {
|
||||
'title': 'Unterstützungs- & Widerstandslinien',
|
||||
@@ -72,9 +99,24 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'reliability': 'Variabel',
|
||||
'target': 'Das nächste große Unterstützungs- oder Widerstandslevel.',
|
||||
'stop_loss': 'Knapp jenseits der gebrochenen Linie (im Falle eines Fehlausbruchs).',
|
||||
},
|
||||
};
|
||||
|
||||
static Color getColorForPattern(String patternType) {
|
||||
const colors = [
|
||||
Colors.amberAccent,
|
||||
Colors.cyanAccent,
|
||||
Colors.purpleAccent,
|
||||
Colors.pinkAccent,
|
||||
Colors.lightGreenAccent,
|
||||
Colors.orangeAccent,
|
||||
];
|
||||
return colors[patternType.hashCode.abs() % colors.length];
|
||||
}
|
||||
|
||||
static void showPatternDetails(BuildContext context, String rawPatternType) {
|
||||
final key = dictionary.keys.firstWhere(
|
||||
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
|
||||
@@ -87,6 +129,9 @@ class PatternExplanations {
|
||||
'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.',
|
||||
'reliability': 'Unbekannt',
|
||||
'target': 'Abhängig vom spezifischen Muster und der Volatilität.',
|
||||
'stop_loss': 'Immer an lokalen Unterstützungs- oder Widerstandszonen platzieren.',
|
||||
};
|
||||
|
||||
final isBullish = info['bias'] == 'BULLISH';
|
||||
@@ -144,9 +189,37 @@ class PatternExplanations {
|
||||
Text(info['significance']!, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Zuverlässigkeit:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 2),
|
||||
Text(info['reliability']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Kursziel (Take Profit):', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['target']!, style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Stop-Loss Platzierung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['stop_loss']!, style: TextStyle(color: AppTheme.accentRed, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
const Text('Empfohlene Trading-Handlung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
|
||||
@@ -2,11 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||
import '../bloc/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';
|
||||
@@ -14,13 +12,17 @@ import 'layouts/asset_page_desktop_layout.dart';
|
||||
import 'layouts/asset_page_mobile_layout.dart';
|
||||
|
||||
class AssetDetailScreen extends StatelessWidget {
|
||||
final String symbol;
|
||||
final String isin;
|
||||
final String? name;
|
||||
final String? symbol;
|
||||
final ApiClient apiClient;
|
||||
|
||||
const AssetDetailScreen({
|
||||
super.key,
|
||||
required this.symbol,
|
||||
required this.isin,
|
||||
this.symbol,
|
||||
required this.apiClient,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -30,25 +32,35 @@ class AssetDetailScreen extends StatelessWidget {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => AssetHeaderBloc(repository: repository)..add(LoadAssetHeader(symbol)),
|
||||
create: (context) => AssetHeaderBloc(repository: repository)
|
||||
..add(LoadAssetHeader(isin, ticker: symbol)),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => AssetFundamentalsBloc(repository: repository)..add(LoadAssetFundamentals(symbol)),
|
||||
create: (context) => AssetFundamentalsBloc(repository: repository),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => AssetTechnicalBloc(repository: repository)..add(LoadAssetTechnical(symbol)),
|
||||
create: (context) => AssetTechnicalBloc(repository: repository),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => AssetTradesBloc(repository: repository)..add(LoadAssetTrades(symbol)),
|
||||
create: (context) => AssetTradesBloc(repository: repository)
|
||||
..add(LoadAssetTrades(isin)),
|
||||
),
|
||||
],
|
||||
child: Scaffold(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 900) {
|
||||
return AssetPageDesktopLayout(symbol: symbol);
|
||||
return AssetPageDesktopLayout(
|
||||
isin: isin,
|
||||
name: name,
|
||||
selectedTicker: symbol,
|
||||
);
|
||||
}
|
||||
return AssetPageMobileLayout(symbol: symbol);
|
||||
return AssetPageMobileLayout(
|
||||
isin: isin,
|
||||
name: name,
|
||||
selectedTicker: symbol,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
+112
-83
@@ -17,15 +17,19 @@ import '../tabs/technical_tab.dart';
|
||||
import '../tabs/trades_tab.dart';
|
||||
|
||||
class AssetPageDesktopLayout extends StatefulWidget {
|
||||
final String symbol;
|
||||
final String isin;
|
||||
final String? name;
|
||||
final String? selectedTicker;
|
||||
|
||||
const AssetPageDesktopLayout({super.key, required this.symbol});
|
||||
const AssetPageDesktopLayout(
|
||||
{super.key, required this.isin, this.selectedTicker, this.name});
|
||||
|
||||
@override
|
||||
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
|
||||
}
|
||||
|
||||
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout> with SingleTickerProviderStateMixin {
|
||||
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
String? _selectedExchange;
|
||||
String? _selectedTicker;
|
||||
@@ -47,21 +51,29 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout> with Si
|
||||
_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));
|
||||
context.read<AssetHeaderBloc>().add(
|
||||
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
|
||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||
ticker: newTicker, forceRefresh: false));
|
||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||
ticker: newTicker, forceRefresh: false));
|
||||
|
||||
final favCubit = context.read<FavoritesCubit>();
|
||||
if (favCubit.state.isFavorite(widget.symbol)) {
|
||||
favCubit.updateFavoriteTicker(widget.symbol, newTicker);
|
||||
if (favCubit.state.isFavorite(widget.isin)) {
|
||||
favCubit.updateFavoriteTicker(widget.isin, 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));
|
||||
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
|
||||
forceRefresh: true,
|
||||
exchange: _selectedExchange,
|
||||
ticker: _selectedTicker));
|
||||
// AssetFundamentalsBloc is omitted here because AssetHeaderBloc already triggers forceRefresh=true
|
||||
// for fundamentals, and the listener below will fetch the updated data with forceRefresh=false.
|
||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||
ticker: _selectedTicker, forceRefresh: true));
|
||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -73,91 +85,108 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout> with Si
|
||||
if (state is AssetHeaderLoaded && state.data != null) {
|
||||
if (_selectedTicker == null) {
|
||||
setState(() {
|
||||
_selectedTicker = state.data!.symbol;
|
||||
_selectedExchange = state.data!.exchange;
|
||||
_selectedTicker = widget.selectedTicker;
|
||||
//_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));
|
||||
}
|
||||
// Re-trigger fundamentals and TA with resolved ticker whenever header loads (e.g. after force refresh)
|
||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
||||
widget.isin,
|
||||
ticker: _selectedTicker,
|
||||
forceRefresh: false));
|
||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
||||
widget.isin,
|
||||
ticker: _selectedTicker,
|
||||
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),
|
||||
final height = constraints.maxHeight.isFinite
|
||||
? constraints.maxHeight
|
||||
: MediaQuery.of(context).size.height;
|
||||
return SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
children: [
|
||||
AssetHeroHeader(
|
||||
isin: widget.isin,
|
||||
name: widget.name ?? widget.isin,
|
||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||
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(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
isDesktopLeftPanel: true),
|
||||
),
|
||||
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(
|
||||
// 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,
|
||||
children: [
|
||||
FundamentalsTab(symbol: _selectedTicker ?? widget.symbol),
|
||||
TradesTab(symbol: widget.symbol),
|
||||
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(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
),
|
||||
TradesTab(symbol: widget.isin),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,22 +17,26 @@ import '../tabs/technical_tab.dart';
|
||||
import '../tabs/trades_tab.dart';
|
||||
|
||||
class AssetPageMobileLayout extends StatefulWidget {
|
||||
final String symbol;
|
||||
final String isin;
|
||||
final String? name;
|
||||
final String? selectedTicker;
|
||||
|
||||
const AssetPageMobileLayout({super.key, required this.symbol});
|
||||
const AssetPageMobileLayout(
|
||||
{super.key, required this.isin, this.selectedTicker, this.name});
|
||||
|
||||
@override
|
||||
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
|
||||
}
|
||||
|
||||
class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout> with SingleTickerProviderStateMixin {
|
||||
class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
String? _selectedExchange;
|
||||
String? _selectedTicker;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
//_selectedTicker = widget.selectedTicker;
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
}
|
||||
|
||||
@@ -44,24 +48,30 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout> with Sing
|
||||
|
||||
void _handleExchangeChanged(String newExchange, String newTicker) {
|
||||
setState(() {
|
||||
_selectedExchange = newExchange;
|
||||
//_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));
|
||||
context.read<AssetHeaderBloc>().add(
|
||||
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
|
||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||
ticker: newTicker, forceRefresh: false));
|
||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||
ticker: newTicker, forceRefresh: false));
|
||||
|
||||
final favCubit = context.read<FavoritesCubit>();
|
||||
if (favCubit.state.isFavorite(widget.symbol)) {
|
||||
favCubit.updateFavoriteTicker(widget.symbol, newTicker);
|
||||
if (favCubit.state.isFavorite(widget.isin)) {
|
||||
favCubit.updateFavoriteTicker(widget.isin, 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));
|
||||
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
|
||||
forceRefresh: true, ticker: _selectedTicker));
|
||||
// AssetFundamentalsBloc is omitted here because AssetHeaderBloc already triggers forceRefresh=true
|
||||
// for fundamentals, and the listener below will fetch the updated data with forceRefresh=false.
|
||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||
ticker: _selectedTicker, forceRefresh: true));
|
||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -73,56 +83,70 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout> with Sing
|
||||
if (state is AssetHeaderLoaded && state.data != null) {
|
||||
if (_selectedTicker == null) {
|
||||
setState(() {
|
||||
_selectedTicker = state.data!.symbol;
|
||||
_selectedExchange = state.data!.exchange;
|
||||
_selectedTicker = widget.selectedTicker;
|
||||
//_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));
|
||||
}
|
||||
// Re-trigger fundamentals and TA with resolved ticker whenever header loads (e.g. after force refresh)
|
||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
||||
widget.isin,
|
||||
ticker: _selectedTicker,
|
||||
forceRefresh: false));
|
||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
||||
widget.isin,
|
||||
ticker: _selectedTicker,
|
||||
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'),
|
||||
],
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: AssetHeroHeader(
|
||||
isin: widget.isin,
|
||||
name: widget.name ?? widget.isin,
|
||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||
onExchangeChanged: _handleExchangeChanged,
|
||||
onForceRefresh: _handleForceRefresh,
|
||||
),
|
||||
theme.cardSurface,
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
FundamentalsTab(symbol: _selectedTicker ?? widget.symbol),
|
||||
TechnicalTab(symbol: _selectedTicker ?? widget.symbol),
|
||||
TradesTab(symbol: widget.symbol),
|
||||
],
|
||||
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(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
),
|
||||
TechnicalTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
),
|
||||
TradesTab(symbol: widget.isin),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -135,11 +159,13 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
|
||||
@override
|
||||
double get minExtent => _tabBar.preferredSize.height;
|
||||
|
||||
@override
|
||||
double get maxExtent => _tabBar.preferredSize.height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
Widget build(
|
||||
BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
return Container(
|
||||
color: _backgroundColor,
|
||||
child: _tabBar,
|
||||
|
||||
@@ -10,8 +10,9 @@ 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});
|
||||
final String isin;
|
||||
final String? symbol;
|
||||
const FundamentalsTab({super.key, this.symbol, required this.isin});
|
||||
|
||||
@override
|
||||
State<FundamentalsTab> createState() => _FundamentalsTabState();
|
||||
@@ -24,15 +25,11 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
@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
|
||||
@@ -55,7 +52,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
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)),
|
||||
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
@@ -515,7 +512,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
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)),
|
||||
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Daten von Backend abrufen'),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/widgets/glass_container.dart';
|
||||
@@ -11,13 +12,15 @@ import '../../utils/pattern_explanations.dart';
|
||||
import '../../widgets/chart/candlestick_chart.dart';
|
||||
|
||||
class TechnicalTab extends StatefulWidget {
|
||||
final String symbol;
|
||||
final String isin;
|
||||
final String? symbol;
|
||||
final bool isDesktopLeftPanel;
|
||||
|
||||
const TechnicalTab({
|
||||
super.key,
|
||||
required this.symbol,
|
||||
this.symbol,
|
||||
this.isDesktopLeftPanel = false,
|
||||
required this.isin,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -38,15 +41,11 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
@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
|
||||
@@ -54,7 +53,8 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||
builder: (context, state) {
|
||||
if (state is AssetTechnicalLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
||||
}
|
||||
|
||||
if (state is AssetTechnicalError) {
|
||||
@@ -66,10 +66,13 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
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)),
|
||||
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)),
|
||||
onPressed: () => context.read<AssetTechnicalBloc>().add(
|
||||
LoadAssetTechnical(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
@@ -87,10 +90,40 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
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();
|
||||
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 = data.patterns
|
||||
.map((p) => ChartPatternModel(
|
||||
type: p.type,
|
||||
upperLine: p.upperLine.map((pt) => PatternPoint(pt.time, pt.price)).toList(),
|
||||
lowerLine: p.lowerLine.map((pt) => PatternPoint(pt.time, pt.price)).toList(),
|
||||
))
|
||||
.toList();
|
||||
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
|
||||
@@ -105,22 +138,47 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
children: [
|
||||
// Glassmorphic Indicator & Pattern Control Ribbon
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
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),
|
||||
_buildIndicatorChip(
|
||||
'EMA (20)',
|
||||
_showEma,
|
||||
(v) => setState(() => _showEma = v),
|
||||
Colors.blueAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip('SMA (50)', _showSma50, (v) => setState(() => _showSma50 = v), Colors.orangeAccent),
|
||||
_buildIndicatorChip(
|
||||
'SMA (50)',
|
||||
_showSma50,
|
||||
(v) => setState(() => _showSma50 = v),
|
||||
Colors.orangeAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip('SMA (200)', _showSma200, (v) => setState(() => _showSma200 = v), Colors.redAccent),
|
||||
_buildIndicatorChip(
|
||||
'SMA (200)',
|
||||
_showSma200,
|
||||
(v) => setState(() => _showSma200 = v),
|
||||
Colors.redAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip('Supertrend', _showSupertrend, (v) => setState(() => _showSupertrend = v), AppTheme.primaryEmerald),
|
||||
_buildIndicatorChip(
|
||||
'Supertrend',
|
||||
_showSupertrend,
|
||||
(v) => setState(() => _showSupertrend = v),
|
||||
AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip('Alle Muster', _showPatterns, (v) => setState(() => _showPatterns = v), Colors.amberAccent),
|
||||
_buildIndicatorChip(
|
||||
'Alle Muster',
|
||||
_showPatterns,
|
||||
(v) => setState(() => _showPatterns = v),
|
||||
Colors.amberAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip('Signale', _showSignals, (v) => setState(() => _showSignals = v), AppTheme.accentCyan),
|
||||
_buildIndicatorChip(
|
||||
'Signale',
|
||||
_showSignals,
|
||||
(v) => setState(() => _showSignals = v),
|
||||
AppTheme.accentCyan),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -156,24 +214,42 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.architecture_outlined, color: AppTheme.primaryEmerald, size: 20),
|
||||
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)),
|
||||
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) {
|
||||
if (_disabledPatternIndices.length ==
|
||||
patterns.length) {
|
||||
_disabledPatternIndices.clear();
|
||||
} else {
|
||||
_disabledPatternIndices.addAll(List.generate(patterns.length, (i) => i));
|
||||
_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)),
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -182,18 +258,33 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
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)),
|
||||
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)),
|
||||
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)),
|
||||
...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)),
|
||||
Text('Strategie-Signale:',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13)),
|
||||
const SizedBox(height: 6),
|
||||
...signals.map((s) => _buildSignalCard(s)),
|
||||
],
|
||||
@@ -208,7 +299,8 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
|
||||
child: Text('Keine technisches Indikatoren verfügbar',
|
||||
style: TextStyle(color: AppTheme.textMuted)),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -216,6 +308,21 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
|
||||
Widget _buildPatternCard(ChartPatternModel pattern, int index) {
|
||||
final isEnabled = !_disabledPatternIndices.contains(index);
|
||||
final patternColor = PatternExplanations.getColorForPattern(pattern.type);
|
||||
|
||||
final allPoints = [...pattern.upperLine, ...pattern.lowerLine];
|
||||
DateTime? startDate;
|
||||
DateTime? endDate;
|
||||
if (allPoints.isNotEmpty) {
|
||||
allPoints.sort((a, b) => a.time.compareTo(b.time));
|
||||
startDate = allPoints.first.time;
|
||||
endDate = allPoints.last.time;
|
||||
}
|
||||
|
||||
final dateFormat = DateFormat('dd.MM.yy');
|
||||
final dateStr = startDate != null && endDate != null
|
||||
? '${dateFormat.format(startDate)} - ${dateFormat.format(endDate)}'
|
||||
: 'Unbekannt';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
@@ -226,9 +333,10 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
// Checkbox for individual pattern toggling on the chart
|
||||
Checkbox(
|
||||
value: isEnabled,
|
||||
activeColor: Colors.amberAccent,
|
||||
activeColor: patternColor,
|
||||
checkColor: Colors.black,
|
||||
side: BorderSide(color: Colors.amberAccent.withValues(alpha: 0.6)),
|
||||
side:
|
||||
BorderSide(color: patternColor.withValues(alpha: 0.6)),
|
||||
onChanged: (bool? val) {
|
||||
setState(() {
|
||||
if (val == true) {
|
||||
@@ -241,19 +349,27 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => PatternExplanations.showPatternDetails(context, pattern.type),
|
||||
onTap: () => PatternExplanations.showPatternDetails(
|
||||
context, pattern.type),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||
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,
|
||||
color: isEnabled
|
||||
? patternColor.withValues(alpha: 0.15)
|
||||
: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.polyline_outlined, color: isEnabled ? Colors.amberAccent : AppTheme.textMuted, size: 20),
|
||||
child: Icon(Icons.polyline_outlined,
|
||||
color: isEnabled
|
||||
? patternColor
|
||||
: AppTheme.textMuted,
|
||||
size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -266,26 +382,33 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
pattern.type,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isEnabled ? Colors.white : AppTheme.textMuted,
|
||||
color: isEnabled
|
||||
? Colors.white
|
||||
: AppTheme.textMuted,
|
||||
fontSize: 14,
|
||||
decoration: isEnabled ? null : TextDecoration.lineThrough,
|
||||
decoration: isEnabled
|
||||
? null
|
||||
: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
|
||||
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),
|
||||
'Zeitraum: $dateStr\n'
|
||||
'Linien: Oben (${pattern.upperLine.length} Pkt.) / Unten (${pattern.lowerLine.length} Pkt.)',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textMuted, fontSize: 11, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusBadge(
|
||||
label: isEnabled ? 'AKTIV' : 'AUS',
|
||||
color: isEnabled ? Colors.amberAccent : AppTheme.textMuted,
|
||||
color:
|
||||
isEnabled ? patternColor : AppTheme.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -314,7 +437,8 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(isBuy ? Icons.north_east : Icons.south_east, color: color, size: 20),
|
||||
child: Icon(isBuy ? Icons.north_east : Icons.south_east,
|
||||
color: color, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -323,13 +447,26 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(signal.type.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 14)),
|
||||
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)),
|
||||
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)),
|
||||
Text(
|
||||
signal.description.isNotEmpty
|
||||
? signal.description
|
||||
: 'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -340,13 +477,18 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIndicatorChip(String label, bool isSelected, ValueChanged<bool> onChanged, 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)),
|
||||
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)),
|
||||
@@ -357,7 +499,8 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
onTap: () => MetricExplanations.show(context, label),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
|
||||
child:
|
||||
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:math';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../utils/pattern_explanations.dart';
|
||||
|
||||
class CandleModel {
|
||||
final DateTime time;
|
||||
@@ -518,16 +519,16 @@ class _CandlePainter extends CustomPainter {
|
||||
}
|
||||
|
||||
if (showEma && !firstEma20) {
|
||||
canvas.drawPath(ema20Path, Paint()..color = theme.primaryColor..style = PaintingStyle.stroke..strokeWidth = 1.5);
|
||||
canvas.drawPath(ema20Path, Paint()..color = Colors.blueAccent..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);
|
||||
canvas.drawPath(sma200Path, Paint()..color = Colors.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
|
||||
}
|
||||
if (showSupertrend && !firstSupertrend) {
|
||||
canvas.drawPath(supertrendPath, Paint()..color = Colors.lightBlueAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
|
||||
canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0);
|
||||
}
|
||||
|
||||
if (showPatterns) {
|
||||
@@ -702,12 +703,13 @@ class _CandlePainter extends CustomPainter {
|
||||
}
|
||||
|
||||
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) {
|
||||
final color = PatternExplanations.getColorForPattern(pattern.type);
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
void drawLine(List<PatternPoint> points) {
|
||||
if (points.length < 2) return;
|
||||
final path = Path();
|
||||
|
||||
@@ -6,19 +6,19 @@ 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';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AssetHeroHeader extends StatelessWidget {
|
||||
final String symbol;
|
||||
final String isin;
|
||||
final String name;
|
||||
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,
|
||||
this.onForceRefresh, required this.isin, required this.name, this.symbol,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -27,10 +27,8 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
|
||||
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)
|
||||
];
|
||||
@@ -43,10 +41,9 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
if (asset != null) {
|
||||
name = asset.name.isNotEmpty ? asset.name : symbol;
|
||||
//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;
|
||||
@@ -54,7 +51,7 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
final selectedOption = tickerOptions.firstWhere(
|
||||
(t) => t.exchange == currentExchange,
|
||||
(t) => t.ticker == symbol || t.exchange == symbol,
|
||||
orElse: () => tickerOptions.first,
|
||||
);
|
||||
|
||||
@@ -89,7 +86,7 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
AssetLogoWidget(symbolOrName: symbol, size: 48),
|
||||
AssetLogoWidget(symbolOrName: isin, imageUrl: asset?.image, size: 48),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -106,7 +103,7 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SelectableText(
|
||||
symbol,
|
||||
isin,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -122,13 +119,24 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Open in Yahoo Finance',
|
||||
icon: Icon(Icons.open_in_new, color: theme.textSecondary),
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://finance.yahoo.com/quote/${selectedOption.ticker}');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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),
|
||||
FavoriteStarButton(symbol: symbol, identifier: isin, name: name),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -142,7 +150,7 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LIVE PRICE',
|
||||
'AKTUELLER PREIS',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -180,15 +188,15 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
),
|
||||
// Interactive Ticker & Exchange Selector Dropdown
|
||||
PopupMenuButton<String>(
|
||||
initialValue: selectedOption.exchange,
|
||||
initialValue: selectedOption.ticker,
|
||||
tooltip: 'Select Exchange & Ticker',
|
||||
onSelected: (newExchange) {
|
||||
onSelected: (newTicker) {
|
||||
if (onExchangeChanged != null) {
|
||||
final opt = tickerOptions.firstWhere(
|
||||
(t) => t.exchange == newExchange,
|
||||
(t) => t.ticker == newTicker,
|
||||
orElse: () => tickerOptions.first,
|
||||
);
|
||||
onExchangeChanged!(newExchange, opt.ticker);
|
||||
onExchangeChanged!(opt.exchange, opt.ticker);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
@@ -196,10 +204,10 @@ class AssetHeroHeader extends StatelessWidget {
|
||||
final ex = opt.exchange;
|
||||
final tick = opt.ticker;
|
||||
final label = '$tick ($ex)';
|
||||
final isSelected = ex == currentExchange;
|
||||
final isSelected = tick == symbol || ex == symbol;
|
||||
|
||||
return PopupMenuItem<String>(
|
||||
value: ex,
|
||||
value: tick,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
|
||||
@@ -20,10 +20,10 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
Future<void> _onFetchEvents(FetchCalendarEvents event, Emitter<CalendarState> emit) async {
|
||||
emit(CalendarLoading());
|
||||
try {
|
||||
final events = await repository.fetchEvents();
|
||||
final events = await repository.fetchEvents(event.year, event.month);
|
||||
emit(CalendarLoaded(
|
||||
allEvents: events,
|
||||
currentMonth: DateTime.now(),
|
||||
currentMonth: DateTime(event.year, event.month),
|
||||
));
|
||||
} catch (e) {
|
||||
emit(const CalendarError("Fehler beim Laden des Kalenders."));
|
||||
@@ -60,6 +60,9 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
currentMonth: event.newMonth,
|
||||
clearSelectedDate: true,
|
||||
));
|
||||
|
||||
// Trigger new fetch for the selected month
|
||||
add(FetchCalendarEvents(year: event.newMonth.year, month: event.newMonth.month));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ abstract class CalendarEvent extends Equatable {
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class FetchCalendarEvents extends CalendarEvent {}
|
||||
class FetchCalendarEvents extends CalendarEvent {
|
||||
final int year;
|
||||
final int month;
|
||||
|
||||
const FetchCalendarEvents({required this.year, required this.month});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [year, month];
|
||||
}
|
||||
|
||||
class FilterCategoryChanged extends CalendarEvent {
|
||||
final String category;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
|
||||
|
||||
class CorporateEventModel extends Equatable {
|
||||
final String id;
|
||||
final String isin;
|
||||
final String symbol;
|
||||
final String companyName;
|
||||
final String eventType;
|
||||
@@ -15,6 +16,7 @@ class CorporateEventModel extends Equatable {
|
||||
required this.eventType,
|
||||
required this.eventDate,
|
||||
required this.description,
|
||||
required this.isin,
|
||||
});
|
||||
|
||||
factory CorporateEventModel.fromJson(Map<String, dynamic> json) {
|
||||
@@ -25,7 +27,8 @@ class CorporateEventModel extends Equatable {
|
||||
if (str.contains('.')) {
|
||||
final parts = str.split('.');
|
||||
if (parts.length >= 3) {
|
||||
return DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
|
||||
return DateTime(
|
||||
int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
|
||||
}
|
||||
}
|
||||
return DateTime.parse(str);
|
||||
@@ -36,17 +39,24 @@ class CorporateEventModel extends Equatable {
|
||||
|
||||
return CorporateEventModel(
|
||||
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? json['Isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '',
|
||||
companyName: json['companyName']?.toString() ?? json['CompanyName']?.toString() ?? '',
|
||||
eventType: json['eventType']?.toString() ?? json['EventType']?.toString() ?? '',
|
||||
companyName: json['companyName']?.toString() ??
|
||||
json['CompanyName']?.toString() ??
|
||||
'',
|
||||
eventType:
|
||||
json['eventType']?.toString() ?? json['EventType']?.toString() ?? '',
|
||||
eventDate: parseDate(json['eventDate'] ?? json['EventDate']),
|
||||
description: json['description']?.toString() ?? json['Description']?.toString() ?? '',
|
||||
description: json['description']?.toString() ??
|
||||
json['Description']?.toString() ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'isin': isin,
|
||||
'symbol': symbol,
|
||||
'companyName': companyName,
|
||||
'eventType': eventType,
|
||||
@@ -56,5 +66,6 @@ class CorporateEventModel extends Equatable {
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, symbol, companyName, eventType, eventDate, description];
|
||||
List<Object?> get props =>
|
||||
[id, symbol, companyName, eventType, eventDate, description];
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ class CalendarRepository {
|
||||
|
||||
CalendarRepository({required this.apiClient});
|
||||
|
||||
Future<List<CorporateEventModel>> fetchEvents() async {
|
||||
Future<List<CorporateEventModel>> fetchEvents(int year, int month) async {
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/calendar');
|
||||
final monthStr = month.toString().padLeft(2, '0');
|
||||
final res = await apiClient.get('/api/v1/calendar/events/$year/$monthStr');
|
||||
if (res.statusCode == 200 && res.data != null) {
|
||||
final List<dynamic> data = res.data;
|
||||
return data.map((json) => CorporateEventModel.fromJson(json)).toList();
|
||||
|
||||
@@ -17,9 +17,12 @@ class CorporateCalendarScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => CalendarBloc(
|
||||
repository: CalendarRepository(apiClient: apiClient),
|
||||
)..add(FetchCalendarEvents()),
|
||||
create: (context) {
|
||||
final now = DateTime.now();
|
||||
return CalendarBloc(
|
||||
repository: CalendarRepository(apiClient: apiClient),
|
||||
)..add(FetchCalendarEvents(year: now.year, month: now.month));
|
||||
},
|
||||
child: _CorporateCalendarScreenContent(apiClient: apiClient),
|
||||
);
|
||||
}
|
||||
@@ -50,125 +53,130 @@ class _CorporateCalendarScreenContent extends StatelessWidget {
|
||||
if (state is CalendarLoaded) {
|
||||
final filtered = state.filteredEvents;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MonthCalendarWidget(
|
||||
currentMonth: state.currentMonth,
|
||||
selectedDate: state.selectedDate,
|
||||
events: state.allEvents.map((e) => e.toJson()).toList(),
|
||||
onDateSelected: (date) {
|
||||
context.read<CalendarBloc>().add(FilterDateSelected(date));
|
||||
},
|
||||
onMonthChanged: (newMonth) {
|
||||
context.read<CalendarBloc>().add(MonthChanged(newMonth));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: categories.map((cat) {
|
||||
final isSelected = state.selectedCategory == cat;
|
||||
String label = 'Alle';
|
||||
if (cat == 'Earnings') label = 'Quartalsergebnisse';
|
||||
if (cat == 'ExDividend') label = 'Ex-Dividendentage';
|
||||
if (cat == 'Payout') label = 'Zahlungstage';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
selectedColor: AppTheme.primaryEmerald.withValues(alpha: 0.25),
|
||||
backgroundColor: AppTheme.glassSurface,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? AppTheme.primaryEmerald : AppTheme.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 12,
|
||||
),
|
||||
side: BorderSide(color: isSelected ? AppTheme.primaryEmerald : AppTheme.glassBorder),
|
||||
onSelected: (_) {
|
||||
context.read<CalendarBloc>().add(FilterCategoryChanged(cat));
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.selectedDate != null)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
context.read<CalendarBloc>().add(const FilterDateSelected(null));
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MonthCalendarWidget(
|
||||
currentMonth: state.currentMonth,
|
||||
selectedDate: state.selectedDate,
|
||||
events: state.allEvents.map((e) => e.toJson()).toList(),
|
||||
onDateSelected: (date) {
|
||||
context.read<CalendarBloc>().add(FilterDateSelected(date));
|
||||
},
|
||||
onMonthChanged: (newMonth) {
|
||||
context.read<CalendarBloc>().add(MonthChanged(newMonth));
|
||||
},
|
||||
icon: Icon(Icons.clear, size: 14, color: AppTheme.accentCyan),
|
||||
label: Text('Alle Tage', style: TextStyle(color: AppTheme.accentCyan, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
state.selectedDate != null
|
||||
? 'Termine am ${state.selectedDate!.day.toString().padLeft(2, '0')}.${state.selectedDate!.month.toString().padLeft(2, '0')}.${state.selectedDate!.year} (${filtered.length})'
|
||||
: 'Anstehende Termine (${filtered.length})',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
|
||||
),
|
||||
Text('Kachelansicht', style: TextStyle(fontSize: 11, color: AppTheme.textMuted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: categories.map((cat) {
|
||||
final isSelected = state.selectedCategory == cat;
|
||||
String label = 'Alle';
|
||||
if (cat == 'Earnings') label = 'Quartalsergebnisse';
|
||||
if (cat == 'ExDividend') label = 'Ex-Dividendentage';
|
||||
if (cat == 'Payout') label = 'Zahlungstage';
|
||||
|
||||
filtered.isEmpty
|
||||
? Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Keine Unternehmenstermine für diesen Filter/Tag gefunden.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
selectedColor: AppTheme.primaryEmerald.withValues(alpha: 0.25),
|
||||
backgroundColor: AppTheme.glassSurface,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? AppTheme.primaryEmerald : AppTheme.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 12,
|
||||
),
|
||||
side: BorderSide(color: isSelected ? AppTheme.primaryEmerald : AppTheme.glassBorder),
|
||||
onSelected: (_) {
|
||||
context.read<CalendarBloc>().add(FilterCategoryChanged(cat));
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.selectedDate != null)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
context.read<CalendarBloc>().add(const FilterDateSelected(null));
|
||||
},
|
||||
icon: Icon(Icons.clear, size: 14, color: AppTheme.accentCyan),
|
||||
label: Text('Alle Tage', style: TextStyle(color: AppTheme.accentCyan, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
state.selectedDate != null
|
||||
? 'Termine am ${state.selectedDate!.day.toString().padLeft(2, '0')}.${state.selectedDate!.month.toString().padLeft(2, '0')}.${state.selectedDate!.year} (${filtered.length})'
|
||||
: 'Anstehende Termine (${filtered.length})',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
|
||||
),
|
||||
Text('Kachelansicht', style: TextStyle(fontSize: 11, color: AppTheme.textMuted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (filtered.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Keine Unternehmenstermine für diesen Filter/Tag gefunden.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final crossAxisCount = constraints.maxWidth > 750 ? 4 : (constraints.maxWidth > 480 ? 2 : 1);
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: filtered.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
childAspectRatio: 3,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
return CalendarEventTile(
|
||||
event: filtered[index].toJson(),
|
||||
apiClient: apiClient,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (filtered.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 300,
|
||||
mainAxisExtent: 135,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return CalendarEventTile(
|
||||
event: filtered[index].toJson(),
|
||||
apiClient: apiClient,
|
||||
);
|
||||
},
|
||||
childCount: filtered.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/asset_utils.dart';
|
||||
import '../../../core/widgets/asset_logo_widget.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/status_badge.dart';
|
||||
@@ -20,12 +19,13 @@ class CalendarEventTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rawSymbol = event['symbol']?.toString() ?? event['Symbol']?.toString() ?? 'ASSET';
|
||||
final rawCompany = event['companyName']?.toString() ?? event['CompanyName']?.toString() ?? rawSymbol;
|
||||
final displayName = AssetUtils.getAssetName(rawCompany.isNotEmpty ? rawCompany : rawSymbol);
|
||||
final isin = event['isin']!;
|
||||
final rawSymbol = event['ticker']?.toString() ?? event['Ticker']?.toString() ?? event['symbol']?.toString() ?? 'ASSET';
|
||||
final companyName = event['companyName']?.toString() ?? event['CompanyName']?.toString() ?? rawSymbol;
|
||||
final type = event['eventType']?.toString() ?? event['EventType']?.toString() ?? 'Earnings';
|
||||
final desc = event['description']?.toString() ?? event['Description']?.toString() ?? '';
|
||||
final dateStr = event['eventDate']?.toString() ?? event['EventDate']?.toString() ?? '';
|
||||
final desc = event['description']?.toString() ?? event['Description']?.toString() ?? '$companyName $type Termin';
|
||||
final dateStr = event['date']?.toString() ?? event['Date']?.toString() ?? event['eventDate']?.toString() ?? '';
|
||||
final image = event['image']?.toString() ?? (isin.isNotEmpty ? '/api/v1/logo/$isin' : null);
|
||||
|
||||
String formattedDate = dateStr;
|
||||
try {
|
||||
@@ -50,7 +50,9 @@ class CalendarEventTile extends StatelessWidget {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AssetDetailScreen(
|
||||
symbol: displayName,
|
||||
isin: isin,
|
||||
name: companyName,
|
||||
symbol: rawSymbol,
|
||||
apiClient: apiClient,
|
||||
),
|
||||
),
|
||||
@@ -66,14 +68,14 @@ class CalendarEventTile extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
AssetLogoWidget(symbolOrName: displayName, size: 28),
|
||||
AssetLogoWidget(symbolOrName: companyName, imageUrl: image, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
companyName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -68,8 +68,13 @@ class _AssetDiscoveryBarState extends State<AssetDiscoveryBar> {
|
||||
itemCount: assets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final asset = assets[index];
|
||||
final identifier = asset.symbol.isNotEmpty ? asset.symbol : asset.isin;
|
||||
final isFav = favState.isFavorite(identifier) || favState.isFavorite(asset.isin);
|
||||
|
||||
final isin = asset.isin;
|
||||
final matches = favState.favoriteDetails.where((e) => e.isin == isin);
|
||||
final symbol = matches.isNotEmpty ? matches.first : null;
|
||||
final symbolOrNull = symbol?.symbol;
|
||||
|
||||
final isFav = symbol != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
@@ -101,7 +106,9 @@ class _AssetDiscoveryBarState extends State<AssetDiscoveryBar> {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AssetDetailScreen(
|
||||
symbol: identifier,
|
||||
isin: isin,
|
||||
name: asset.name,
|
||||
symbol: symbolOrNull,
|
||||
apiClient: widget.apiClient,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,12 +43,9 @@ class FavoritesCarousel extends StatelessWidget {
|
||||
itemCount: favoritesList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final fav = favoritesList[index];
|
||||
final displayName = fav.name.isNotEmpty
|
||||
? fav.name
|
||||
: (fav.symbol.isNotEmpty ? fav.symbol : fav.isin);
|
||||
final isinOrSymbol = fav.isin.isNotEmpty
|
||||
? fav.isin
|
||||
: (fav.symbol.isNotEmpty ? fav.symbol : fav.name);
|
||||
final displayName = fav.name;
|
||||
final isin = fav.isin;
|
||||
final symbol = fav.symbol.isNotEmpty ? fav.symbol : null;
|
||||
|
||||
final isPositive = fav.change24h >= 0;
|
||||
|
||||
@@ -62,7 +59,9 @@ class FavoritesCarousel extends StatelessWidget {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AssetDetailScreen(
|
||||
symbol: isinOrSymbol,
|
||||
isin: isin,
|
||||
name: displayName,
|
||||
symbol: symbol,
|
||||
apiClient: apiClient,
|
||||
),
|
||||
),
|
||||
@@ -75,7 +74,7 @@ class FavoritesCarousel extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
AssetLogoWidget(
|
||||
symbolOrName: isinOrSymbol,
|
||||
symbolOrName: isin,
|
||||
imageUrl: fav.image.isNotEmpty ? fav.image : null,
|
||||
size: 24,
|
||||
),
|
||||
@@ -92,7 +91,7 @@ class FavoritesCarousel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
FavoriteStarButton(
|
||||
identifier: isinOrSymbol,
|
||||
identifier: isin,
|
||||
symbol: fav.symbol,
|
||||
name: fav.name,
|
||||
size: 18,
|
||||
|
||||
@@ -110,7 +110,9 @@ class _TradesStreamWidgetContent extends StatelessWidget {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => AssetDetailScreen(
|
||||
symbol: p.symbol.isNotEmpty ? p.symbol : p.isin,
|
||||
isin: p.isin,
|
||||
name: p.companyName,
|
||||
symbol: p.symbol.isNotEmpty ? p.symbol : null,
|
||||
apiClient: context.read<TradeBloc>().repository.apiClient,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:finlytic_app/features/favorites/repositories/favorites_repository.dart';
|
||||
import 'favorites_event.dart';
|
||||
import 'favorites_state.dart';
|
||||
|
||||
class FavoritesBloc extends Bloc<FavoritesEvent, FavoritesState> {
|
||||
final FavoritesRepository repository;
|
||||
|
||||
FavoritesBloc({required this.repository}) : super(FavoritesInitial()) {
|
||||
on<LoadFavorites>(_onLoadFavorites);
|
||||
}
|
||||
|
||||
Future<void> _onLoadFavorites(LoadFavorites event, Emitter<FavoritesState> emit) async {
|
||||
emit(FavoritesLoading());
|
||||
try {
|
||||
final favorites = await repository.fetchFavoritesDetails(event.symbols);
|
||||
emit(FavoritesLoaded(favorites));
|
||||
} catch (e) {
|
||||
emit(const FavoritesError("Fehler beim Laden der Favoriten."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class FavoritesEvent extends Equatable {
|
||||
const FavoritesEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class LoadFavorites extends FavoritesEvent {
|
||||
final List<String> symbols;
|
||||
|
||||
const LoadFavorites(this.symbols);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [symbols];
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:finlytic_app/features/favorites/models/favorite_asset_model.dart';
|
||||
|
||||
abstract class FavoritesState extends Equatable {
|
||||
const FavoritesState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class FavoritesInitial extends FavoritesState {}
|
||||
|
||||
class FavoritesLoading extends FavoritesState {}
|
||||
|
||||
class FavoritesLoaded extends FavoritesState {
|
||||
final List<FavoriteAssetModel> favorites;
|
||||
|
||||
const FavoritesLoaded(this.favorites);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [favorites];
|
||||
}
|
||||
|
||||
class FavoritesError extends FavoritesState {
|
||||
final String message;
|
||||
|
||||
const FavoritesError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/signalr_service.dart';
|
||||
import '../../../core/utils/asset_utils.dart';
|
||||
import '../models/favorite_asset_model.dart';
|
||||
|
||||
class FavoritesState extends Equatable {
|
||||
@@ -63,7 +62,8 @@ class FavoritesCubit extends Cubit<FavoritesState> {
|
||||
Future<void> loadFavorites() async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/user/favorites');
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final res = await apiClient.get('/api/v1/user/favorites?_t=$ts');
|
||||
if (res.statusCode == 200 && res.data is List) {
|
||||
final rawList = res.data as List;
|
||||
final set = <String>{};
|
||||
@@ -71,7 +71,6 @@ class FavoritesCubit extends Cubit<FavoritesState> {
|
||||
|
||||
for (var item in rawList) {
|
||||
final model = FavoriteAssetModel.fromJson(Map<String, dynamic>.from(item));
|
||||
AssetUtils.registerAsset(model.isin, model.name, model.image);
|
||||
final key = (model.isin.isNotEmpty ? model.isin : (model.symbol.isNotEmpty ? model.symbol : model.name)).toUpperCase();
|
||||
if (!dedupMap.containsKey(key)) {
|
||||
dedupMap[key] = model;
|
||||
@@ -150,10 +149,21 @@ class FavoritesCubit extends Cubit<FavoritesState> {
|
||||
|
||||
Future<void> updateFavoriteTicker(String symbol, String ticker) async {
|
||||
try {
|
||||
// Optimistic UI update
|
||||
final target = symbol.toUpperCase();
|
||||
final updatedDetails = state.favoriteDetails.map((model) {
|
||||
if (model.isin.toUpperCase() == target || model.symbol.toUpperCase() == target) {
|
||||
return model.copyWith(symbol: ticker);
|
||||
}
|
||||
return model;
|
||||
}).toList();
|
||||
emit(state.copyWith(favoriteDetails: updatedDetails));
|
||||
|
||||
await apiClient.post('/api/v1/user/favorites/$symbol/ticker?ticker=$ticker');
|
||||
await loadFavorites();
|
||||
} catch (_) {
|
||||
// Ignore gracefully
|
||||
// Revert/refresh on error
|
||||
await loadFavorites();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/features/favorites/models/favorite_asset_model.dart';
|
||||
import 'package:finlytic_app/core/utils/asset_utils.dart';
|
||||
|
||||
class FavoritesRepository {
|
||||
final ApiClient apiClient;
|
||||
|
||||
FavoritesRepository({required this.apiClient});
|
||||
|
||||
Future<List<FavoriteAssetModel>> fetchFavoritesDetails(List<String> symbols) async {
|
||||
if (symbols.isEmpty) return [];
|
||||
|
||||
try {
|
||||
final res = await apiClient.post('/api/v1/assets/batch', data: {'symbols': symbols});
|
||||
if (res.statusCode == 200 && res.data != null) {
|
||||
final List<dynamic> data = res.data;
|
||||
return data.map((json) => FavoriteAssetModel.fromJson(json)).toList();
|
||||
}
|
||||
return symbols.map((s) => FavoriteAssetModel(
|
||||
symbol: s,
|
||||
name: AssetUtils.getAssetName(s),
|
||||
currentPrice: 0.0,
|
||||
change24h: 0.0,
|
||||
)).toList();
|
||||
} catch (e) {
|
||||
print('Error fetching favorites details: $e');
|
||||
return symbols.map((s) => FavoriteAssetModel(
|
||||
symbol: s,
|
||||
name: AssetUtils.getAssetName(s),
|
||||
currentPrice: 0.0,
|
||||
change24h: 0.0,
|
||||
)).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/asset_utils.dart';
|
||||
import '../../../core/widgets/asset_logo_widget.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../shared/widgets/favorite_star_button.dart';
|
||||
@@ -23,7 +22,7 @@ class WatchlistCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final activeTheme = AppTheme.activePreset;
|
||||
final symbol = asset.symbol;
|
||||
final displayName = asset.name.isNotEmpty ? asset.name : AssetUtils.getAssetName(symbol);
|
||||
final displayName = asset.name;
|
||||
final isPositive = asset.change24h >= 0;
|
||||
|
||||
return GlassContainer(
|
||||
@@ -32,7 +31,9 @@ class WatchlistCard extends StatelessWidget {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AssetDetailScreen(
|
||||
symbol: displayName,
|
||||
isin: asset.isin,
|
||||
name: displayName,
|
||||
symbol: asset.symbol,
|
||||
apiClient: apiClient,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:finlytic_app/features/favorites/cubit/favorites_cubit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/time_utils.dart';
|
||||
@@ -47,6 +49,8 @@ class NewsCardItem extends StatelessWidget {
|
||||
final matchedAssetsRaw = article['MatchedAssets'] ?? article['matchedAssets'];
|
||||
final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : [];
|
||||
|
||||
final favourites = context.read<FavoritesCubit>().state.favoriteDetails;
|
||||
|
||||
return GlassContainer(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
onTap: () {
|
||||
@@ -101,9 +105,12 @@ class NewsCardItem extends StatelessWidget {
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: matchedAssets.map((assetItem) {
|
||||
final assetSymbol = assetItem['Name']?.toString() ?? assetItem['name']?.toString() ?? assetItem['Isin']?.toString() ?? assetItem['isin']?.toString() ?? 'ASSET';
|
||||
final isin = assetItem['isin']!;
|
||||
final name = assetItem['name']!;
|
||||
final match = favourites.where((e) => e.isin == isin);
|
||||
final symbol = match.isEmpty ? null : match.first;
|
||||
return ActionChip(
|
||||
label: Text(assetSymbol, style: TextStyle(fontSize: 10, color: AppTheme.accentCyan)),
|
||||
label: Text(name, style: TextStyle(fontSize: 10, color: AppTheme.accentCyan)),
|
||||
backgroundColor: AppTheme.glassSurface,
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
@@ -112,7 +119,9 @@ class NewsCardItem extends StatelessWidget {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AssetDetailScreen(
|
||||
symbol: assetSymbol,
|
||||
isin: assetItem,
|
||||
name: name,
|
||||
symbol: symbol != null ? symbol.symbol : null,
|
||||
apiClient: apiClient,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/features/search/models/search_result_model.dart';
|
||||
import 'package:finlytic_app/core/utils/asset_utils.dart';
|
||||
|
||||
class SearchRepository {
|
||||
final ApiClient apiClient;
|
||||
@@ -9,15 +8,12 @@ class SearchRepository {
|
||||
|
||||
Future<List<SearchResultModel>> searchAssets(String query) async {
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/assets/search', queryParameters: {'q': query});
|
||||
final res = await apiClient
|
||||
.get('/api/v1/assets/search', queryParameters: {'q': query});
|
||||
if (res.statusCode == 200 && res.data != null) {
|
||||
final list = res.data as List;
|
||||
final results = list.map((json) => SearchResultModel.fromJson(json)).toList();
|
||||
for (final item in results) {
|
||||
if (item.isinCode.isNotEmpty && item.displayName.isNotEmpty) {
|
||||
AssetUtils.registerAsset(item.isinCode, item.displayName, item.image);
|
||||
}
|
||||
}
|
||||
final results =
|
||||
list.map((json) => SearchResultModel.fromJson(json)).toList();
|
||||
return results;
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/asset_utils.dart';
|
||||
import '../../../core/widgets/asset_logo_widget.dart';
|
||||
import '../../../core/widgets/shimmer_loading.dart';
|
||||
import '../../../shared/widgets/favorite_star_button.dart';
|
||||
@@ -138,13 +137,9 @@ class _AssetSearchDialogContentState extends State<_AssetSearchDialogContent> {
|
||||
itemBuilder: (context, index) {
|
||||
final item = results[index];
|
||||
final assetName = item.displayName;
|
||||
final isinCode = item.isinCode;
|
||||
final isin = item.isin;
|
||||
|
||||
if (isinCode.isNotEmpty && assetName.isNotEmpty) {
|
||||
AssetUtils.registerAsset(isinCode, assetName, item.image);
|
||||
}
|
||||
|
||||
final targetId = isinCode.isNotEmpty ? isinCode : assetName;
|
||||
final targetId = isin.isNotEmpty ? isin : assetName;
|
||||
|
||||
return ListTile(
|
||||
leading: AssetLogoWidget(
|
||||
@@ -153,8 +148,8 @@ class _AssetSearchDialogContentState extends State<_AssetSearchDialogContent> {
|
||||
size: 36,
|
||||
),
|
||||
title: Text(assetName, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
subtitle: isinCode.isNotEmpty
|
||||
? Text('ISIN: $isinCode', style: TextStyle(color: activeTheme.textMuted, fontSize: 11))
|
||||
subtitle: isin.isNotEmpty
|
||||
? Text('ISIN: $isin', style: TextStyle(color: activeTheme.textMuted, fontSize: 11))
|
||||
: null,
|
||||
trailing: SizedBox(
|
||||
width: 40,
|
||||
@@ -171,7 +166,8 @@ class _AssetSearchDialogContentState extends State<_AssetSearchDialogContent> {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AssetDetailScreen(
|
||||
symbol: assetName,
|
||||
isin: isin,
|
||||
name: assetName,
|
||||
apiClient: widget.apiClient,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ class TradeRepository {
|
||||
if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin;
|
||||
if (status != null && status.isNotEmpty) queryParams['status'] = status;
|
||||
|
||||
final response = await apiClient.get('/api/v1/trades', queryParameters: queryParams);
|
||||
final response = await apiClient.get('/api/v1/user/trades', queryParameters: queryParams);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final List<dynamic> data = response.data;
|
||||
@@ -34,8 +34,9 @@ class TradeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeTrade(String id) async {
|
||||
final response = await apiClient.post('/api/v1/user/trades/$id/close');
|
||||
Future<void> closeTrade(String id, {double? exitPrice}) async {
|
||||
final body = exitPrice != null ? {'userExitPrice': exitPrice} : null;
|
||||
final response = await apiClient.post('/api/v1/user/trades/$id/close', data: body);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Trade konnte nicht geschlossen werden');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user