diff --git a/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_bloc.dart b/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_bloc.dart index 41c45a5..fd954a5 100644 --- a/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_bloc.dart +++ b/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_bloc.dart @@ -15,5 +15,32 @@ class AssetTechnicalBloc extends Bloc emit(AssetTechnicalError(e.toString())); } }); + + on((event, emit) { + if (state is AssetTechnicalLoaded) { + final current = state as AssetTechnicalLoaded; + final updated = Set.from(current.disabledPatternIndices); + if (event.enabled) { + updated.remove(event.patternIndex); + } else { + updated.add(event.patternIndex); + } + emit(current.copyWith(disabledPatternIndices: updated)); + } + }); + + on((event, emit) { + if (state is AssetTechnicalLoaded) { + final current = state as AssetTechnicalLoaded; + emit(current.copyWith( + showSma50: event.showSma50, + showSma200: event.showSma200, + showEma: event.showEma, + showSupertrend: event.showSupertrend, + showPatterns: event.showPatterns, + showSignals: event.showSignals, + )); + } + }); } } diff --git a/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_event.dart b/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_event.dart index 5550d9e..00457f9 100644 --- a/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_event.dart +++ b/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_event.dart @@ -1,7 +1,32 @@ abstract class AssetTechnicalEvent {} + class LoadAssetTechnical extends AssetTechnicalEvent { final String isin; final bool forceRefresh; final String? ticker; LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.ticker}); } + +class TogglePatternFilter extends AssetTechnicalEvent { + final int patternIndex; + final bool enabled; + TogglePatternFilter({required this.patternIndex, required this.enabled}); +} + +class ToggleIndicatorFilter extends AssetTechnicalEvent { + final bool? showSma50; + final bool? showSma200; + final bool? showEma; + final bool? showSupertrend; + final bool? showPatterns; + final bool? showSignals; + + ToggleIndicatorFilter({ + this.showSma50, + this.showSma200, + this.showEma, + this.showSupertrend, + this.showPatterns, + this.showSignals, + }); +} diff --git a/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_state.dart b/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_state.dart index c1ab18d..c555816 100644 --- a/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_state.dart +++ b/FinlyticApp/lib/features/asset_detail/bloc/technical/asset_technical_state.dart @@ -1,12 +1,55 @@ import '../../models/technical_analysis_model.dart'; abstract class AssetTechnicalState {} + class AssetTechnicalInitial extends AssetTechnicalState {} + class AssetTechnicalLoading extends AssetTechnicalState {} + class AssetTechnicalLoaded extends AssetTechnicalState { final TechnicalAnalysisModel? data; - AssetTechnicalLoaded(this.data); + final Set disabledPatternIndices; + final bool showSma50; + final bool showSma200; + final bool showEma; + final bool showSupertrend; + final bool showPatterns; + final bool showSignals; + + AssetTechnicalLoaded( + this.data, { + this.disabledPatternIndices = const {}, + this.showSma50 = true, + this.showSma200 = true, + this.showEma = true, + this.showSupertrend = true, + this.showPatterns = true, + this.showSignals = true, + }); + + AssetTechnicalLoaded copyWith({ + TechnicalAnalysisModel? data, + Set? disabledPatternIndices, + bool? showSma50, + bool? showSma200, + bool? showEma, + bool? showSupertrend, + bool? showPatterns, + bool? showSignals, + }) { + return AssetTechnicalLoaded( + data ?? this.data, + disabledPatternIndices: disabledPatternIndices ?? this.disabledPatternIndices, + showSma50: showSma50 ?? this.showSma50, + showSma200: showSma200 ?? this.showSma200, + showEma: showEma ?? this.showEma, + showSupertrend: showSupertrend ?? this.showSupertrend, + showPatterns: showPatterns ?? this.showPatterns, + showSignals: showSignals ?? this.showSignals, + ); + } } + class AssetTechnicalError extends AssetTechnicalState { final String message; AssetTechnicalError(this.message); diff --git a/FinlyticApp/lib/features/asset_detail/models/technical_analysis_model.dart b/FinlyticApp/lib/features/asset_detail/models/technical_analysis_model.dart index b4c5c13..2e9ad12 100644 --- a/FinlyticApp/lib/features/asset_detail/models/technical_analysis_model.dart +++ b/FinlyticApp/lib/features/asset_detail/models/technical_analysis_model.dart @@ -198,15 +198,16 @@ class ChartPatternModel extends Equatable { class TechnicalAnalysisModel extends Equatable { final String symbol; final String currency; + final double? currentPrice; final String trend; final String rsi; final String macd; final String overallSignal; final String sma50; final String sma200; - final double vix; - final String sp500Trend; - final double dxy; + final double? vix; + final String? sp500Trend; + final double? dxy; final double? stopLossAtr; final List candles; final List indicators; @@ -216,15 +217,16 @@ class TechnicalAnalysisModel extends Equatable { const TechnicalAnalysisModel({ required this.symbol, this.currency = 'EUR', + this.currentPrice, required this.trend, required this.rsi, required this.macd, required this.overallSignal, required this.sma50, required this.sma200, - this.vix = 16.5, - this.sp500Trend = 'Bullish', - this.dxy = 104.2, + this.vix, + this.sp500Trend, + this.dxy, this.stopLossAtr, this.candles = const [], this.indicators = const [], @@ -245,7 +247,6 @@ class TechnicalAnalysisModel extends Equatable { var rawPatterns = json['patterns'] as List? ?? []; var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map)).toList(); - final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null; final regime = json['marketRegime'] as Map?; @@ -259,17 +260,18 @@ class TechnicalAnalysisModel extends Equatable { } return TechnicalAnalysisModel( - symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '', + symbol: json['symbol']?.toString() ?? '', currency: json['currency']?.toString() ?? 'EUR', + currentPrice: (json['currentPrice'] 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, + vix: (regime?['vixValue'] as num?)?.toDouble(), + sp500Trend: regime?['marketTrend']?.toString(), + dxy: (regime?['dxyValue'] as num?)?.toDouble(), stopLossAtr: lastInd?.recommendedStopLoss, candles: candlesList, indicators: indicatorsList, diff --git a/FinlyticApp/lib/features/asset_detail/views/fullscreen_chart_screen.dart b/FinlyticApp/lib/features/asset_detail/views/fullscreen_chart_screen.dart new file mode 100644 index 0000000..144c503 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/views/fullscreen_chart_screen.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../core/theme/app_theme.dart'; +import '../bloc/technical/asset_technical_bloc.dart'; +import '../bloc/technical/asset_technical_event.dart'; +import '../bloc/technical/asset_technical_state.dart'; +import '../widgets/chart/candlestick_chart.dart'; +import '../widgets/technical/indicator_ribbon_bar.dart'; + +class FullscreenChartScreen extends StatefulWidget { + final String isin; + final String? symbol; + final AssetTechnicalBloc technicalBloc; + + const FullscreenChartScreen({ + super.key, + required this.isin, + this.symbol, + required this.technicalBloc, + }); + + static Future open(BuildContext context, {required String isin, String? symbol}) { + final bloc = context.read(); + return Navigator.of(context).push( + PageRouteBuilder( + opaque: true, + pageBuilder: (ctx, anim, secAnim) => FullscreenChartScreen( + isin: isin, + symbol: symbol, + technicalBloc: bloc, + ), + transitionsBuilder: (ctx, anim, secAnim, child) { + return FadeTransition(opacity: anim, child: child); + }, + ), + ); + } + + @override + State createState() => _FullscreenChartScreenState(); +} + +class _FullscreenChartScreenState extends State { + @override + void initState() { + super.initState(); + // Rotate to landscape on mobile devices + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + + @override + void dispose() { + // Restore orientation back to default portrait/auto + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppTheme.activePreset; + + return BlocProvider.value( + value: widget.technicalBloc, + child: Scaffold( + backgroundColor: theme.darkBackground, + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + if (state is AssetTechnicalLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is AssetTechnicalLoaded && state.data != null) { + final data = state.data!; + final activePatterns = []; + for (int i = 0; i < data.patterns.length; i++) { + if (!state.disabledPatternIndices.contains(i)) { + activePatterns.add(data.patterns[i]); + } + } + + return Column( + children: [ + _buildHeader(context, theme, state), + Expanded( + child: LayoutBuilder( + builder: (ctx, constraints) { + return CandlestickChart( + candles: data.candles, + indicators: data.indicators, + patterns: activePatterns, + signals: data.signals, + showSma50: state.showSma50, + showSma200: state.showSma200, + showEma: state.showEma, + showPatterns: state.showPatterns, + showSignals: state.showSignals, + showSupertrend: state.showSupertrend, + height: constraints.maxHeight, + isFullscreen: true, + onToggleFullscreen: () => Navigator.of(context).pop(), + ); + }, + ), + ), + ], + ); + } + + return Center( + child: Text('Keine Chartdaten verfügbar', style: TextStyle(color: theme.textMuted)), + ); + }, + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context, ThemePreset theme, AssetTechnicalLoaded state) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: theme.cardSurface, + border: Border(bottom: BorderSide(color: theme.glassBorder)), + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, size: 20, color: Colors.white70), + tooltip: 'Zurück', + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(width: 4), + Text( + widget.symbol ?? widget.isin, + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14), + ), + const SizedBox(width: 12), + Expanded( + child: IndicatorRibbonBar( + showSma50: state.showSma50, + showSma200: state.showSma200, + showEma: state.showEma, + showSupertrend: state.showSupertrend, + showPatterns: state.showPatterns, + showSignals: state.showSignals, + onToggleSma50: (v) => context.read().add(ToggleIndicatorFilter(showSma50: v)), + onToggleSma200: (v) => context.read().add(ToggleIndicatorFilter(showSma200: v)), + onToggleEma: (v) => context.read().add(ToggleIndicatorFilter(showEma: v)), + onToggleSupertrend: (v) => context.read().add(ToggleIndicatorFilter(showSupertrend: v)), + onTogglePatterns: (v) => context.read().add(ToggleIndicatorFilter(showPatterns: v)), + onToggleSignals: (v) => context.read().add(ToggleIndicatorFilter(showSignals: v)), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.fullscreen_exit, size: 22, color: Colors.white70), + tooltip: 'Vollbild beenden', + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/views/tabs/technical_tab.dart b/FinlyticApp/lib/features/asset_detail/views/tabs/technical_tab.dart index 429e6f7..89f43ca 100644 --- a/FinlyticApp/lib/features/asset_detail/views/tabs/technical_tab.dart +++ b/FinlyticApp/lib/features/asset_detail/views/tabs/technical_tab.dart @@ -1,18 +1,19 @@ 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'; import '../../../../core/widgets/shimmer_loading.dart'; -import '../../../../core/widgets/status_badge.dart'; import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_event.dart'; import '../../bloc/technical/asset_technical_state.dart'; -import '../../utils/metric_explanations.dart'; -import '../../utils/pattern_explanations.dart'; import '../../widgets/chart/candlestick_chart.dart'; +import '../../widgets/technical/pattern_card_item.dart'; +import '../../widgets/technical/signal_card_item.dart'; +import '../../widgets/technical/indicator_ribbon_bar.dart'; -class TechnicalTab extends StatefulWidget { +import '../fullscreen_chart_screen.dart'; + +class TechnicalTab extends StatelessWidget { final String isin; final String? symbol; final bool isDesktopLeftPanel; @@ -30,31 +31,6 @@ class TechnicalTab extends StatefulWidget { required this.isin, }); - @override - State createState() => _TechnicalTabState(); -} - -class _TechnicalTabState extends State { - bool _showSma50 = true; - bool _showSma200 = true; - bool _showEma = true; - bool _showPatterns = true; - bool _showSignals = true; - bool _showSupertrend = true; - - // Set of disabled pattern indices for individual toggling - final Set _disabledPatternIndices = {}; - - @override - void initState() { - super.initState(); - } - - @override - void didUpdateWidget(covariant TechnicalTab oldWidget) { - super.didUpdateWidget(oldWidget); - } - @override Widget build(BuildContext context) { return BlocBuilder( @@ -73,12 +49,14 @@ class _TechnicalTabState extends State { 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)), + 'Fehler beim Laden der Technischen Analyse: ${state.message}', + style: const TextStyle(color: Colors.white70), + ), const SizedBox(height: 16), ElevatedButton.icon( onPressed: () => context.read().add( - LoadAssetTechnical(widget.isin, ticker: widget.symbol, forceRefresh: true)), + LoadAssetTechnical(isin, ticker: symbol, forceRefresh: true), + ), icon: const Icon(Icons.refresh), label: const Text('Erneut versuchen'), ), @@ -88,221 +66,91 @@ class _TechnicalTabState extends State { ); } - if (state is AssetTechnicalLoaded) { - final data = state.data; - List candles = []; - List patterns = []; - List signals = []; - List indicators = []; + if (state is AssetTechnicalLoaded && state.data != null) { + final data = state.data!; + final candles = data.candles; + final patterns = data.patterns; + final signals = data.signals; + final indicators = data.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 = 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(); + final activePatterns = []; + for (int i = 0; i < patterns.length; i++) { + if (!state.disabledPatternIndices.contains(i)) { + activePatterns.add(patterns[i]); + } } - // Filter patterns according to individual checkbox states - final activePatterns = [ - for (int i = 0; i < patterns.length; i++) - if (!_disabledPatternIndices.contains(i)) patterns[i] - ]; - - final chartRibbon = GlassContainer( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildIndicatorChip( - 'EMA (20)', - _showEma, - (v) => setState(() => _showEma = v), - Colors.blueAccent), - const SizedBox(width: 6), - _buildIndicatorChip( - 'SMA (50)', - _showSma50, - (v) => setState(() => _showSma50 = v), - Colors.orangeAccent), - const SizedBox(width: 6), - _buildIndicatorChip( - 'SMA (200)', - _showSma200, - (v) => setState(() => _showSma200 = v), - Colors.redAccent), - const SizedBox(width: 6), - _buildIndicatorChip( - 'Supertrend', - _showSupertrend, - (v) => setState(() => _showSupertrend = v), - AppTheme.primaryEmerald), - const SizedBox(width: 6), - _buildIndicatorChip( - 'Alle Muster', - _showPatterns, - (v) => setState(() => _showPatterns = v), - Colors.amberAccent), - const SizedBox(width: 6), - _buildIndicatorChip( - 'Signale', - _showSignals, - (v) => setState(() => _showSignals = v), - AppTheme.accentCyan), - ], - ), - ), + final chartWidget = CandlestickChart( + candles: candles, + indicators: indicators, + patterns: activePatterns, + signals: signals, + showSma50: state.showSma50, + showSma200: state.showSma200, + showEma: state.showEma, + showPatterns: state.showPatterns, + showSignals: state.showSignals, + showSupertrend: state.showSupertrend, + height: chartHeight, + onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol), ); - final chartWidget = SizedBox( - height: widget.chartHeight, - width: double.infinity, - child: CandlestickChart( - candles: candles, - patterns: activePatterns, - signals: signals, - indicators: indicators, - showPatterns: _showPatterns, - showEma: _showEma, - showSma50: _showSma50, - showSma200: _showSma200, - showSignals: _showSignals, - showSupertrend: _showSupertrend, - ), + final chartRibbon = IndicatorRibbonBar( + showSma50: state.showSma50, + showSma200: state.showSma200, + showEma: state.showEma, + showSupertrend: state.showSupertrend, + showPatterns: state.showPatterns, + showSignals: state.showSignals, + onToggleSma50: (v) => context.read().add(ToggleIndicatorFilter(showSma50: v)), + onToggleSma200: (v) => context.read().add(ToggleIndicatorFilter(showSma200: v)), + onToggleEma: (v) => context.read().add(ToggleIndicatorFilter(showEma: v)), + onToggleSupertrend: (v) => context.read().add(ToggleIndicatorFilter(showSupertrend: v)), + onTogglePatterns: (v) => context.read().add(ToggleIndicatorFilter(showPatterns: v)), + onToggleSignals: (v) => context.read().add(ToggleIndicatorFilter(showSignals: v)), + onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol), ); - if (widget.showChartOnly) { + if (showChartOnly) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ chartRibbon, - const SizedBox(height: 8), + const SizedBox(height: 10), chartWidget, ], ); } - final detailsSection = Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.architecture_outlined, - color: AppTheme.primaryEmerald, size: 20), - const SizedBox(width: 8), - const Text('Erkannte Chart-Muster & Signale', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white)), - ], - ), - if (patterns.isNotEmpty) - TextButton.icon( - onPressed: () { - setState(() { - if (_disabledPatternIndices.length == - patterns.length) { - _disabledPatternIndices.clear(); - } else { - _disabledPatternIndices.addAll( - List.generate( - patterns.length, (i) => i)); - } - }); - }, - icon: Icon( - _disabledPatternIndices.isEmpty - ? Icons.deselect - : Icons.select_all, - size: 16, - color: Colors.amberAccent), - label: Text( - _disabledPatternIndices.isEmpty - ? 'Alle abwählen' - : 'Alle anwählen', - style: const TextStyle( - color: Colors.amberAccent, fontSize: 12)), - ), - ], - ), - const SizedBox(height: 12), - if (patterns.isEmpty && signals.isEmpty) - GlassContainer( - padding: const EdgeInsets.all(16), - child: Center( - child: Text( - 'Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.', - style: TextStyle( - color: AppTheme.textMuted, fontSize: 12)), - ), - ) - else ...[ - if (patterns.isNotEmpty) ...[ - Text( - 'Formationen & Trendlinien (Mit Checkbox im Chart schalten):', - style: TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - fontSize: 13)), - const SizedBox(height: 6), - ...List.generate( - patterns.length, - (index) => - _buildPatternCard(patterns[index], index)), - const SizedBox(height: 12), - ], - if (signals.isNotEmpty) ...[ - Text('Strategie-Signale:', - style: TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - fontSize: 13)), - const SizedBox(height: 6), - ...signals.map((s) => _buildSignalCard(s)), - ], - ], + final detailsSection = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (patterns.isNotEmpty) ...[ + const Text('Erkannte Chartformationen & Muster', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + const SizedBox(height: 8), + for (int i = 0; i < patterns.length; i++) + PatternCardItem( + pattern: patterns[i], + index: i, + isEnabled: !state.disabledPatternIndices.contains(i), + onToggle: (enabled) { + context.read().add( + TogglePatternFilter(patternIndex: i, enabled: enabled), + ); + }, + ), + const SizedBox(height: 16), ], - ), + if (signals.isNotEmpty) ...[ + const Text('Strategische Kauf- & Verkaufssignale', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + const SizedBox(height: 8), + for (final sig in signals) SignalCardItem(signal: sig), + ], + ], ); - if (widget.showDetailsOnly) { + if (showDetailsOnly) { return SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 16), child: detailsSection, @@ -325,228 +173,26 @@ class _TechnicalTabState extends State { } 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)), ); }, ); } - 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), - child: GlassContainer( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - children: [ - // Checkbox for individual pattern toggling on the chart - Checkbox( - value: isEnabled, - activeColor: patternColor, - checkColor: Colors.black, - side: - BorderSide(color: patternColor.withValues(alpha: 0.6)), - onChanged: (bool? val) { - setState(() { - if (val == true) { - _disabledPatternIndices.remove(index); - } else { - _disabledPatternIndices.add(index); - } - }); - }, - ), - Expanded( - child: InkWell( - onTap: () => PatternExplanations.showPatternDetails( - context, pattern.type), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 4, horizontal: 4), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: isEnabled - ? patternColor.withValues(alpha: 0.15) - : AppTheme.glassSurface, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.polyline_outlined, - color: isEnabled - ? patternColor - : AppTheme.textMuted, - size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - pattern.type, - style: TextStyle( - fontWeight: FontWeight.bold, - color: isEnabled - ? Colors.white - : AppTheme.textMuted, - fontSize: 14, - decoration: isEnabled - ? null - : TextDecoration.lineThrough, - ), - ), - const SizedBox(width: 6), - Icon(Icons.info_outline, - size: 14, color: AppTheme.textMuted), - ], - ), - Text( - '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 ? patternColor : AppTheme.textMuted, - ), - ], - ), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildSignalCard(StrategySignalModel signal) { - final isBuy = signal.type.toUpperCase() == 'BUY'; - final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: GlassContainer( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(isBuy ? Icons.north_east : Icons.south_east, - color: color, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(signal.type.toUpperCase(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: color, - fontSize: 14)), - const SizedBox(width: 8), - Text('@ €${signal.price.toStringAsFixed(2)}', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 13)), - ], - ), - const SizedBox(height: 4), - Text( - signal.description.isNotEmpty - ? signal.description - : 'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.', - style: TextStyle( - color: AppTheme.textSecondary, fontSize: 12)), - ], - ), - ), - StatusBadge(label: 'SIGNAL', color: color), - ], - ), - ), - ); - } - - Widget _buildIndicatorChip(String label, bool isSelected, - ValueChanged onChanged, Color color) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - FilterChip( - selected: isSelected, - label: Text(label, - style: TextStyle( - color: isSelected ? Colors.black : color, - fontSize: 11, - fontWeight: FontWeight.bold)), - selectedColor: color, - backgroundColor: color.withValues(alpha: 0.15), - side: BorderSide(color: color.withValues(alpha: 0.4)), - showCheckmark: false, - onSelected: onChanged, - ), - InkWell( - onTap: () => MetricExplanations.show(context, label), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: - Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted), - ), - ), - ], - ); - } - Widget _buildTechnicalShimmer(BuildContext context) { - if (widget.showChartOnly) { + if (showChartOnly) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12), const SizedBox(height: 8), - ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16), + ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16), ], ); } - if (widget.showDetailsOnly) { + if (showDetailsOnly) { return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Column( @@ -570,7 +216,7 @@ class _TechnicalTabState extends State { children: [ const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12), const SizedBox(height: 8), - ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16), + ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16), const SizedBox(height: 16), const ShimmerLoading(width: 240, height: 20, borderRadius: 6), const SizedBox(height: 14), diff --git a/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart b/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart index 3313662..e354c74 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart @@ -1,124 +1,25 @@ -import 'dart:math'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../../../core/theme/app_theme.dart'; -import '../../utils/pattern_explanations.dart'; +import '../../models/technical_analysis_model.dart'; +import 'candlestick_painter.dart'; -class CandleModel { - final DateTime time; - final double open; - final double high; - final double low; - final double close; - final double volume; - - CandleModel({ - required this.time, - required this.open, - required this.high, - required this.low, - required this.close, - required this.volume, - }); - - factory CandleModel.fromJson(Map json) { - return CandleModel( - time: DateTime.tryParse(json['timestamp'] ?? json['time'] ?? '') ?? DateTime.now(), - open: (json['open'] ?? 0).toDouble(), - high: (json['high'] ?? 0).toDouble(), - low: (json['low'] ?? 0).toDouble(), - close: (json['close'] ?? 0).toDouble(), - volume: (json['volume'] ?? 0).toDouble(), - ); - } -} - -class IndicatorModel { - final DateTime timestamp; - final double? ema20; - final double? sma50; - final double? sma200; - final double? supertrendUpper; - final double? supertrendLower; - final String? supertrendDirection; - - IndicatorModel({ - required this.timestamp, - this.ema20, - this.sma50, - this.sma200, - this.supertrendUpper, - this.supertrendLower, - this.supertrendDirection, - }); - - factory IndicatorModel.fromJson(Map json) { - return IndicatorModel( - timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), - ema20: json['ema20'] != null ? (json['ema20'] as num).toDouble() : null, - sma50: json['sma50'] != null ? (json['sma50'] as num).toDouble() : null, - sma200: json['sma200'] != null ? (json['sma200'] as num).toDouble() : null, - supertrendUpper: json['supertrendUpper'] != null ? (json['supertrendUpper'] as num).toDouble() : null, - supertrendLower: json['supertrendLower'] != null ? (json['supertrendLower'] as num).toDouble() : null, - supertrendDirection: json['supertrendDirection']?.toString(), - ); - } -} - -class PatternPoint { - final DateTime time; - final double price; - PatternPoint(this.time, this.price); - factory PatternPoint.fromJson(Map json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble()); -} - -class ChartPatternModel { - final String type; - final List upperLine; - final List lowerLine; - - ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine}); - - factory ChartPatternModel.fromJson(Map json) { - return ChartPatternModel( - type: json['type'] ?? '', - upperLine: (json['upperLine'] as List? ?? []).map((e) => PatternPoint.fromJson(e)).toList(), - lowerLine: (json['lowerLine'] as List? ?? []).map((e) => PatternPoint.fromJson(e)).toList(), - ); - } -} - -class StrategySignalModel { - final String type; - final DateTime timestamp; - final String direction; - final double price; - final String description; - - StrategySignalModel({required this.type, required this.timestamp, required this.direction, required this.price, required this.description}); - - factory StrategySignalModel.fromJson(Map json) { - return StrategySignalModel( - type: json['type'] ?? '', - timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), - direction: json['direction'] ?? '', - price: (json['price'] as num).toDouble(), - description: json['description'] ?? '', - ); - } -} +export '../../models/technical_analysis_model.dart' show CandleModel, IndicatorModel, ChartPatternModel, PatternPoint, StrategySignalModel; class CandlestickChart extends StatefulWidget { final List candles; final List patterns; final List signals; final List indicators; - final bool showPatterns; final bool showSma50; final bool showSma200; final bool showEma; + final bool showPatterns; final bool showSignals; final bool showSupertrend; + final double height; + final bool isFullscreen; + final VoidCallback? onToggleFullscreen; const CandlestickChart({ super.key, @@ -126,12 +27,15 @@ class CandlestickChart extends StatefulWidget { this.patterns = const [], this.signals = const [], this.indicators = const [], - this.showPatterns = true, this.showSma50 = true, this.showSma200 = true, this.showEma = true, + this.showPatterns = true, this.showSignals = true, this.showSupertrend = true, + this.height = 420, + this.isFullscreen = false, + this.onToggleFullscreen, }); @override @@ -141,81 +45,142 @@ class CandlestickChart extends StatefulWidget { class _CandlestickChartState extends State { double _scale = 1.0; double _panOffset = 0.0; - + double _baseScale = 1.0; + double _basePanOffset = 0.0; + Offset _startFocalPoint = Offset.zero; + bool _isDragging = false; Offset? _tapPosition; CandleModel? _selectedCandle; @override - Widget build(BuildContext context) { - if (widget.candles.isEmpty) { - return const Center(child: Text('No chart data')); - } + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _fitLatestCandles(); + }); + } + @override + void didUpdateWidget(covariant CandlestickChart oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.candles.length != widget.candles.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _fitLatestCandles(); + }); + } + } + + void _fitLatestCandles() { + if (widget.candles.isEmpty || !mounted) return; + final renderBox = context.findRenderObject() as RenderBox?; + final width = (renderBox?.size.width ?? 600) - 60; + final double candleWidth = 10.0 * _scale; + final double totalCandleSpace = candleWidth + (5.0 * _scale); + final double futureSpace = totalCandleSpace * 10; + final double totalWidth = (widget.candles.length * totalCandleSpace) + futureSpace; + + setState(() { + if (totalWidth > width) { + _panOffset = width - totalWidth; + } else { + _panOffset = 0.0; + } + }); + } + + void _applyZoom(double factor, [double? focalX]) { + if (widget.candles.isEmpty || !mounted) return; + final renderBox = context.findRenderObject() as RenderBox?; + final chartWidth = (renderBox?.size.width ?? 600) - 60; + final fx = focalX ?? (chartWidth / 2); + + setState(() { + final oldScale = _scale; + _scale = (_scale * factor).clamp(0.1, 6.0); + _panOffset = fx - ((fx - _panOffset) * (_scale / oldScale)); + _clampPanOffset(chartWidth); + }); + } + + void _clampPanOffset(double chartWidth) { + if (widget.candles.isEmpty) return; + final double totalCandleSpace = (10.0 + 5.0) * _scale; + final double totalWidth = (widget.candles.length * totalCandleSpace) + (totalCandleSpace * 10); + + if (totalWidth <= chartWidth) { + _panOffset = 0.0; + } else { + final double minPan = chartWidth - totalWidth - 30; + const double maxPan = 30.0; + _panOffset = _panOffset.clamp(minPan, maxPan); + } + } + + @override + Widget build(BuildContext context) { final theme = AppTheme.activePreset; - return LayoutBuilder( - builder: (context, constraints) { - final double baseWidth = 10.0; - final double spacing = 5.0; - final double totalCandleSpace = (baseWidth + spacing) * _scale; - final double totalContentWidth = (widget.candles.length + 15) * totalCandleSpace; - - final double minOffset = constraints.maxWidth - totalContentWidth - 60.0; - final double maxOffset = 100.0; - - _panOffset = _panOffset.clamp(minOffset < maxOffset ? minOffset : maxOffset, maxOffset); - - return Listener( + return Container( + height: widget.height, + decoration: BoxDecoration( + color: theme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.glassBorder), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Listener( onPointerSignal: (pointerSignal) { if (pointerSignal is PointerScrollEvent) { - GestureBinding.instance.pointerSignalResolver.register( - pointerSignal, - (event) { - if (event is PointerScrollEvent) { - setState(() { - final double localX = event.localPosition.dx; - final double zoomFactor = event.scrollDelta.dy > 0 ? 0.9 : 1.1; - final double newScale = (_scale * zoomFactor).clamp(0.2, 5.0); - final double scaleRatio = newScale / _scale; - - // Zoom centered on cursor - _panOffset = localX - (localX - _panOffset) * scaleRatio; - _scale = newScale; - - final double updatedCandleSpace = (baseWidth + spacing) * _scale; - final double updatedContentWidth = (widget.candles.length + 15) * updatedCandleSpace; - final double newMinOffset = constraints.maxWidth - updatedContentWidth - 60.0; - _panOffset = _panOffset.clamp(newMinOffset < maxOffset ? newMinOffset : maxOffset, maxOffset); - }); - } - }, - ); + if (pointerSignal.scrollDelta.dx != 0) { + final renderBox = context.findRenderObject() as RenderBox?; + final chartWidth = (renderBox?.size.width ?? 600) - 60; + setState(() { + _panOffset -= pointerSignal.scrollDelta.dx; + _clampPanOffset(chartWidth); + }); + } else if (pointerSignal.scrollDelta.dy != 0) { + final zoomFactor = pointerSignal.scrollDelta.dy < 0 ? 1.15 : 0.85; + _applyZoom(zoomFactor, pointerSignal.localPosition.dx); + } } }, - child: GestureDetector( - onScaleUpdate: (details) { - setState(() { - _scale = (_scale * details.scale).clamp(0.2, 5.0); - _panOffset += details.focalPointDelta.dx; - _panOffset = _panOffset.clamp(minOffset, maxOffset); - if (_tapPosition != null) { - _handleTap(Offset(_tapPosition!.dx + details.focalPointDelta.dx, _tapPosition!.dy), constraints.maxWidth); - } - }); - }, - onScaleEnd: (_) => setState(() { - _tapPosition = null; - _selectedCandle = null; - }), - onTapDown: (details) { - _handleTap(details.localPosition, constraints.maxWidth); - }, - child: Stack( - children: [ - ClipRect( - child: CustomPaint( + child: MouseRegion( + cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.grab, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onScaleStart: (details) { + _baseScale = _scale; + _basePanOffset = _panOffset; + _startFocalPoint = details.focalPoint; + setState(() => _isDragging = true); + }, + onScaleUpdate: (details) { + final renderBox = context.findRenderObject() as RenderBox?; + final chartWidth = (renderBox?.size.width ?? 600) - 60; + setState(() { + if (details.scale != 1.0) { + final oldScale = _scale; + _scale = (_baseScale * details.scale).clamp(0.1, 6.0); + final fx = details.localFocalPoint.dx; + _panOffset = fx - ((fx - _basePanOffset) * (_scale / oldScale)); + } else { + _panOffset = _basePanOffset + (details.focalPoint.dx - _startFocalPoint.dx); + } + _clampPanOffset(chartWidth); + }); + }, + onScaleEnd: (details) { + setState(() => _isDragging = false); + }, + onTapDown: (details) { + _handleTap(details.localPosition); + }, + child: Stack( + children: [ + CustomPaint( size: Size.infinite, - painter: _CandlePainter( + painter: CandlestickPainter( candles: widget.candles, patterns: widget.patterns, signals: widget.signals, @@ -232,74 +197,76 @@ class _CandlestickChartState extends State { tapPosition: _tapPosition, ), ), - ), - if (_selectedCandle != null) _buildTooltip(theme), - // Floating Zoom & Pan Controls (Top-Left) - Positioned( - left: 12, - top: 12, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - decoration: BoxDecoration( - color: theme.cardSurface.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: theme.glassBorder), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.zoom_in, size: 18), - color: theme.primaryColor, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - onPressed: () => setState(() => _scale = (_scale * 1.25).clamp(0.2, 5.0)), - tooltip: 'Zoom In', - ), - IconButton( - icon: const Icon(Icons.zoom_out, size: 18), - color: theme.primaryColor, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - onPressed: () => setState(() => _scale = (_scale * 0.8).clamp(0.2, 5.0)), - tooltip: 'Zoom Out', - ), - IconButton( - icon: const Icon(Icons.center_focus_strong, size: 18), - color: theme.textMuted, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - onPressed: () => setState(() { - _scale = 1.0; - _panOffset = 0.0; - }), - tooltip: 'Reset Zoom & Pan', - ), - ], - ), - ), - ), - ], + if (_selectedCandle != null) _buildTooltip(theme), + _buildZoomControls(theme), + ], + ), ), ), - ); - }, + ), + ), ); } - void _handleTap(Offset pos, double width) { + Widget _buildZoomControls(ThemePreset theme) { + return Positioned( + right: 10, + bottom: 28, + child: Container( + decoration: BoxDecoration( + color: theme.cardSurface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.glassBorder), + ), + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildZoomButton(icon: Icons.chevron_left, tooltip: 'Nach links bewegen', onTap: () { + final renderBox = context.findRenderObject() as RenderBox?; + setState(() { _panOffset += 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); }); + }), + _buildZoomButton(icon: Icons.chevron_right, tooltip: 'Nach rechts bewegen', onTap: () { + final renderBox = context.findRenderObject() as RenderBox?; + setState(() { _panOffset -= 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); }); + }), + Container(width: 1, height: 16, color: theme.glassBorder), + _buildZoomButton(icon: Icons.add, tooltip: 'Vergrößern', onTap: () => _applyZoom(1.25)), + _buildZoomButton(icon: Icons.remove, tooltip: 'Verkleinern', onTap: () => _applyZoom(0.8)), + _buildZoomButton(icon: Icons.fit_screen_outlined, tooltip: 'Aktuelle Kerzen einpassen', onTap: _fitLatestCandles), + _buildZoomButton(icon: Icons.refresh, tooltip: 'Zoom 1:1 zurücksetzen', onTap: () { setState(() => _scale = 1.0); _fitLatestCandles(); }), + if (widget.onToggleFullscreen != null) ...[ + Container(width: 1, height: 16, color: theme.glassBorder), + _buildZoomButton( + icon: widget.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + tooltip: widget.isFullscreen ? 'Vollbild beenden' : 'Vollbildmodus (Querformat)', + onTap: widget.onToggleFullscreen!, + ), + ], + ], + ), + ), + ); + } + + Widget _buildZoomButton({required IconData icon, required String tooltip, required VoidCallback onTap}) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(icon, size: 16, color: Colors.white70), + ), + ), + ); + } + + void _handleTap(Offset pos) { if (widget.candles.isEmpty) return; - - // Right side is for axis, don't tap there - if (pos.dx > width - 60) return; - - final double baseWidth = 10.0; - final double spacing = 5.0; - final double candleWidth = baseWidth * _scale; - final double totalCandleSpace = candleWidth + (spacing * _scale); - - // dx = (i * totalCandleSpace) + _panOffset; - // (dx - _panOffset) / totalCandleSpace = i; + final double candleWidth = 10.0 * _scale; + final double totalCandleSpace = candleWidth + (5.0 * _scale); final int index = ((pos.dx - _panOffset) / totalCandleSpace).round(); if (index >= 0 && index < widget.candles.length) { @@ -311,9 +278,9 @@ class _CandlestickChartState extends State { } Widget _buildTooltip(ThemePreset theme) { - final candle = _selectedCandle!; - final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.day.toString().padLeft(2,'0')}"; - + final c = _selectedCandle!; + final dStr = "${c.timestamp.year}-${c.timestamp.month.toString().padLeft(2, '0')}-${c.timestamp.day.toString().padLeft(2, '0')}"; + return Positioned( left: 10, top: 10, @@ -328,492 +295,12 @@ class _CandlestickChartState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(dateStr, style: TextStyle(color: theme.textMuted, fontSize: 12)), - Text('O: ${candle.open.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)), - Text('H: ${candle.high.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)), - Text('L: ${candle.low.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)), - Text('C: ${candle.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)), - Text('Vol: ${candle.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)), + Text(dStr, style: TextStyle(color: theme.textMuted, fontSize: 12)), + Text('O: ${c.open.toStringAsFixed(2)} | H: ${c.high.toStringAsFixed(2)} | L: ${c.low.toStringAsFixed(2)} | C: ${c.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)), + Text('Vol: ${c.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textSecondary, fontSize: 11)), ], ), ), ); } } - -class _CandlePainter extends CustomPainter { - final List candles; - final List patterns; - final List signals; - final List indicators; - final double scale; - final double panOffset; - final ThemePreset theme; - final bool showPatterns; - final bool showSma50; - final bool showSma200; - final bool showEma; - final bool showSignals; - final bool showSupertrend; - final Offset? tapPosition; - - final double rightPadding = 60.0; // Space for price axis - final double bottomPadding = 20.0; // Space for X-axis labels - - _CandlePainter({ - required this.candles, - required this.patterns, - required this.signals, - required this.indicators, - required this.scale, - required this.panOffset, - required this.theme, - required this.showPatterns, - required this.showSma50, - required this.showSma200, - required this.showEma, - required this.showSignals, - required this.showSupertrend, - this.tapPosition, - }); - - @override - void paint(Canvas canvas, Size size) { - if (candles.isEmpty) return; - - final double chartWidth = size.width - rightPadding; - final double baseWidth = 10.0; - final double spacing = 5.0; - final double candleWidth = baseWidth * scale; - final double totalCandleSpace = candleWidth + (spacing * scale); - - double maxPrice = 0; - double minPrice = double.infinity; - - // Find min/max in view - int firstVisibleIndex = -1; - for (int i = 0; i < candles.length; i++) { - final dx = (i * totalCandleSpace) + panOffset; - if (dx + candleWidth > 0 && dx < chartWidth) { - if (firstVisibleIndex == -1) firstVisibleIndex = i; - final c = candles[i]; - if (c.high > maxPrice) maxPrice = c.high; - if (c.low < minPrice) minPrice = c.low; - } - } - - if (minPrice == double.infinity || maxPrice == 0) return; - - // Add 10% padding to top/bottom - final range = maxPrice - minPrice; - maxPrice += range * 0.1; - minPrice -= range * 0.1; - final paddedRange = maxPrice - minPrice; - if (paddedRange <= 0) return; - - final double chartHeight = size.height - bottomPadding; - final double volumeHeight = chartHeight * 0.15; // Bottom 15% for volume - final double candleAreaHeight = chartHeight - volumeHeight; - - double maxVolume = 0; - for (int i = 0; i < candles.length; i++) { - if (candles[i].volume > maxVolume) maxVolume = candles[i].volume; - } - if (maxVolume == 0) maxVolume = 1; - - _drawGridAndAxis(canvas, size, chartWidth, candleAreaHeight, minPrice, maxPrice, paddedRange); - - final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill; - final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill; - final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5; - final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5; - - final ema20Path = Path(); - final sma50Path = Path(); - final sma200Path = Path(); - final supertrendPath = Path(); - bool firstEma20 = true; - bool firstSma50 = true; - bool firstSma200 = true; - bool firstSupertrend = true; - - // Map DateTime to X for patterns and signals - double getXForTime(DateTime t) { - int bestIndex = 0; - int minDiff = 999999999; - for (int i = 0; i < candles.length; i++) { - final diff = candles[i].time.difference(t).inSeconds.abs(); - if (diff < minDiff) { - minDiff = diff; - bestIndex = i; - } - } - return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2; - } - - double getYForPrice(double price) { - return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight; - } - - // Clip to chart area so we don't draw over the axis - canvas.save(); - canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight)); - - for (int i = 0; i < candles.length; i++) { - final candle = candles[i]; - final isBullish = candle.close >= candle.open; - - final dx = (i * totalCandleSpace) + panOffset; - if (dx < -candleWidth || dx > chartWidth) continue; // Culling - - final yHigh = getYForPrice(candle.high); - final yLow = getYForPrice(candle.low); - final yOpen = getYForPrice(candle.open); - final yClose = getYForPrice(candle.close); - - // Draw Wick - canvas.drawLine( - Offset(dx + candleWidth / 2, yHigh), - Offset(dx + candleWidth / 2, yLow), - isBullish ? paintWickBullish : paintWickBearish, - ); - - // Draw Body - final top = min(yOpen, yClose); - final bottom = max(yOpen, yClose); - final bodyHeight = max(bottom - top, 1.0); // minimum 1px height - - canvas.drawRect( - Rect.fromLTWH(dx, top, candleWidth, bodyHeight), - isBullish ? paintBullish : paintBearish, - ); - - // Draw Volume - final vHeight = (candle.volume / maxVolume) * volumeHeight; - final vTop = chartHeight - vHeight; - canvas.drawRect( - Rect.fromLTWH(dx, vTop, candleWidth, vHeight), - Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill, - ); - - // Indicators mapping by time - if (indicators.isNotEmpty) { - final cx = dx + candleWidth / 2; - IndicatorModel? match; - for (var ind in indicators) { - if (ind.timestamp.isAtSameMomentAs(candle.time) || ind.timestamp.difference(candle.time).inHours.abs() < 12) { - match = ind; - break; - } - } - - if (match != null) { - if (showEma && match.ema20 != null) { - final y = getYForPrice(match.ema20!); - if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; } - else { ema20Path.lineTo(cx, y); } - } - if (showSma50 && match.sma50 != null) { - final y = getYForPrice(match.sma50!); - if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; } - else { sma50Path.lineTo(cx, y); } - } - if (showSma200 && match.sma200 != null) { - final y = getYForPrice(match.sma200!); - if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; } - else { sma200Path.lineTo(cx, y); } - } - - if (showSupertrend) { - final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper; - if (stVal != null) { - final y = getYForPrice(stVal); - if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; } - else { supertrendPath.lineTo(cx, y); } - } - } - } - } - } - - if (showEma && !firstEma20) { - canvas.drawPath(ema20Path, Paint()..color = 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.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0); - } - if (showSupertrend && !firstSupertrend) { - canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0); - } - - if (showPatterns) { - _drawPatterns(canvas, getXForTime, getYForPrice); - _drawFutureProjectionZone(canvas, size, chartWidth, candleAreaHeight, getXForTime, getYForPrice); - } - - if (showSignals) { - _drawSignals(canvas, getXForTime, getYForPrice); - } - - if (tapPosition != null && tapPosition!.dx < chartWidth) { - _drawCrosshair(canvas, size, chartWidth, chartHeight); - } - - canvas.restore(); // Restore clip - } - - void _drawFutureProjectionZone(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double Function(DateTime) getX, double Function(double) getY) { - if (candles.isEmpty) return; - - final lastCandle = candles.last; - final double lastX = getX(lastCandle.time); - - if (lastX < chartWidth) { - // 1. Shaded background for Future Zone (No divider line) - final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight); - final futureBgPaint = Paint() - ..color = const Color(0xFF001F3F).withValues(alpha: 0.25) - ..style = PaintingStyle.fill; - canvas.drawRect(futureRect, futureBgPaint); - - // Label for Future Zone - final textPainter = TextPainter(textDirection: TextDirection.ltr); - textPainter.text = TextSpan( - text: 'PROGNOSE (MUSTER-SCHÄTZUNG)', - style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8), - ); - textPainter.layout(); - textPainter.paint(canvas, Offset(lastX + 8, 8)); - - // 2. Projected Ghost Candles & Target Line for active patterns - for (var pattern in patterns) { - if (pattern.lowerLine.isNotEmpty || pattern.upperLine.isNotEmpty) { - final targetPrice = pattern.lowerLine.isNotEmpty ? pattern.lowerLine.last.price : (pattern.upperLine.isNotEmpty ? pattern.upperLine.last.price : 0); - if (targetPrice > 0) { - final targetY = getY(targetPrice.toDouble()); - final int numSteps = 10; - final double stepWidth = (chartWidth - lastX - 30) / numSteps; - if (stepWidth <= 0) continue; - - final isBullish = targetPrice >= lastCandle.close; - final projColor = isBullish ? Colors.greenAccent : Colors.redAccent; - - double currX = lastX; - double currPrice = lastCandle.close; - - final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps; - - for (int k = 1; k <= numSteps; k++) { - final nextX = lastX + k * stepWidth; - final waveNoise = sin(k * 0.8) * (priceDeltaPerStep.abs() * 0.3); - final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise; - - final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.2; - final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.2; - - final yOpen = getY(currPrice); - final yClose = getY(nextPrice); - final yHigh = getY(highPrice); - final yLow = getY(lowPrice); - - final cWidth = max(stepWidth * 0.6, 3.0); - final cLeft = nextX - cWidth / 2; - - final isStepBullish = nextPrice >= currPrice; - final stepColor = isStepBullish ? Colors.greenAccent : Colors.redAccent; - - // Draw Ghost Candle Wick - canvas.drawLine( - Offset(nextX, yHigh), - Offset(nextX, yLow), - Paint()..color = stepColor.withValues(alpha: 0.4)..strokeWidth = 1.0, - ); - - // Draw Ghost Candle Body - final top = min(yOpen, yClose); - final bottom = max(yOpen, yClose); - canvas.drawRect( - Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.0)), - Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill, - ); - - currX = nextX; - currPrice = nextPrice; - } - - // Target Price Badge at final step - final targetX = currX; - final targetBadgePainter = TextPainter( - text: TextSpan( - text: ' ZIEL: ${targetPrice.toStringAsFixed(2)} € ', - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), - ), - textDirection: TextDirection.ltr, - ); - targetBadgePainter.layout(); - - final badgeRect = RRect.fromLTRBR( - targetX - targetBadgePainter.width / 2, - targetY - targetBadgePainter.height / 2 - 2, - targetX + targetBadgePainter.width / 2, - targetY + targetBadgePainter.height / 2 + 2, - const Radius.circular(6), - ); - canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.9)); - targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2)); - } - } - } - } - } - - void _drawGridAndAxis(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double minPrice, double maxPrice, double range) { - final gridPaint = Paint() - ..color = theme.glassBorder - ..strokeWidth = 1; - - final textPainter = TextPainter(textDirection: TextDirection.ltr); - - // Y Axis - final int gridLines = 5; - for (int i = 0; i <= gridLines; i++) { - final y = candleAreaHeight - (i / gridLines) * candleAreaHeight; - final price = minPrice + (i / gridLines) * range; - - canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint); - - textPainter.text = TextSpan( - text: price.toStringAsFixed(2), - style: TextStyle(color: theme.textMuted, fontSize: 11), - ); - textPainter.layout(); - textPainter.paint(canvas, Offset(chartWidth + 5, y - 6)); - } - - // X Axis - if (candles.isEmpty) return; - - final double baseWidth = 10.0; - final double spacing = 5.0; - final double candleWidth = baseWidth * scale; - final double totalCandleSpace = candleWidth + (spacing * scale); - - final int xSteps = (chartWidth / 80).floor(); // label every 80px - if (xSteps <= 0) return; - - for (int i = 1; i < xSteps; i++) { - double x = i * (chartWidth / xSteps); - int candleIndex = ((x - panOffset) / totalCandleSpace).round(); - if (candleIndex >= 0 && candleIndex < candles.length) { - final t = candles[candleIndex].time; - textPainter.text = TextSpan( - text: "${t.month.toString().padLeft(2,'0')}-${t.day.toString().padLeft(2,'0')}", - style: TextStyle(color: theme.textMuted, fontSize: 10), - ); - textPainter.layout(); - textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4)); - canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint); - } - } - } - - void _drawPatterns(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) { - 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 points) { - if (points.length < 2) return; - final path = Path(); - path.moveTo(getX(points[0].time), getY(points[0].price)); - for (int i = 1; i < points.length; i++) { - path.lineTo(getX(points[i].time), getY(points[i].price)); - } - canvas.drawPath(path, paint); - } - - drawLine(pattern.upperLine); - drawLine(pattern.lowerLine); - } - } - - void _drawSignals(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) { - for (var signal in signals) { - final x = getX(signal.timestamp); - final y = getY(signal.price); - - final isBuy = signal.direction.toUpperCase() == 'BUY'; - final isSell = signal.direction.toUpperCase() == 'SELL'; - - if (!isBuy && !isSell) continue; - - final color = isBuy ? theme.primaryColor : theme.accentRed; - final label = isBuy ? '▲ BUY' : '▼ SELL'; - - // Draw Pill Badge for Signal - final textPainter = TextPainter( - text: TextSpan( - text: label, - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), - ), - textDirection: TextDirection.ltr, - ); - textPainter.layout(); - - final badgeWidth = textPainter.width + 12; - final badgeHeight = textPainter.height + 6; - final badgeY = isBuy ? y + 12 : y - badgeHeight - 12; - final badgeRect = RRect.fromLTRBR( - x - badgeWidth / 2, - badgeY, - x + badgeWidth / 2, - badgeY + badgeHeight, - const Radius.circular(10), - ); - - // Pill Background - canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95)); - - // Pointer Line to price point - canvas.drawLine( - Offset(x, y), - Offset(x, isBuy ? badgeY : badgeY + badgeHeight), - Paint()..color = color..strokeWidth = 1.5, - ); - - // Text paint - textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3)); - } - } - - void _drawCrosshair(Canvas canvas, Size size, double chartWidth, double chartHeight) { - final paint = Paint() - ..color = theme.textMuted.withValues(alpha: 0.5) - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; - - // Vertical - canvas.drawLine(Offset(tapPosition!.dx, 0), Offset(tapPosition!.dx, chartHeight), paint); - // Horizontal - if (tapPosition!.dy <= chartHeight) { - canvas.drawLine(Offset(0, tapPosition!.dy), Offset(chartWidth, tapPosition!.dy), paint); - } - } - - @override - bool shouldRepaint(covariant _CandlePainter oldDelegate) { - return oldDelegate.scale != scale || - oldDelegate.panOffset != panOffset || - oldDelegate.candles != candles || - oldDelegate.patterns != patterns || - oldDelegate.signals != signals || - oldDelegate.indicators != indicators || - oldDelegate.tapPosition != tapPosition; - } -} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_painter.dart b/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_painter.dart new file mode 100644 index 0000000..277d28d --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_painter.dart @@ -0,0 +1,292 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../models/technical_analysis_model.dart'; +import 'chart_overlay_renderer.dart'; + +class CandlestickPainter extends CustomPainter { + final List candles; + final List patterns; + final List signals; + final List indicators; + final double scale; + final double panOffset; + final ThemePreset theme; + final bool showPatterns; + final bool showSma50; + final bool showSma200; + final bool showEma; + final bool showSignals; + final bool showSupertrend; + final Offset? tapPosition; + + final double rightPadding = 60.0; + final double bottomPadding = 20.0; + + CandlestickPainter({ + required this.candles, + required this.patterns, + required this.signals, + required this.indicators, + required this.scale, + required this.panOffset, + required this.theme, + required this.showPatterns, + required this.showSma50, + required this.showSma200, + required this.showEma, + required this.showSignals, + required this.showSupertrend, + this.tapPosition, + }); + + @override + void paint(Canvas canvas, Size size) { + if (candles.isEmpty) return; + + final double chartWidth = size.width - rightPadding; + final double baseWidth = 10.0; + final double spacing = 5.0; + final double candleWidth = baseWidth * scale; + final double totalCandleSpace = candleWidth + (spacing * scale); + + double maxPrice = 0; + double minPrice = double.infinity; + + int visibleCount = 0; + for (int i = 0; i < candles.length; i++) { + final dx = (i * totalCandleSpace) + panOffset; + if (dx + candleWidth > -100 && dx < chartWidth + 100) { + visibleCount++; + final c = candles[i]; + if (c.high > maxPrice) maxPrice = c.high; + if (c.low < minPrice) minPrice = c.low; + } + } + + if (visibleCount == 0 || minPrice == double.infinity || maxPrice <= 0) { + for (var c in candles) { + if (c.high > maxPrice) maxPrice = c.high; + if (c.low < minPrice) minPrice = c.low; + } + } + + if (minPrice == double.infinity || maxPrice <= 0) return; + + final range = maxPrice - minPrice; + maxPrice += max(range * 0.1, 1.0); + minPrice -= max(range * 0.1, 1.0); + final paddedRange = maxPrice - minPrice; + if (paddedRange <= 0) return; + + final double chartHeight = size.height - bottomPadding; + final double volumeHeight = chartHeight * 0.15; + final double candleAreaHeight = chartHeight - volumeHeight; + + double maxVolume = 0; + for (int i = 0; i < candles.length; i++) { + if (candles[i].volume > maxVolume) maxVolume = candles[i].volume; + } + if (maxVolume == 0) maxVolume = 1; + + ChartOverlayRenderer.drawGridAndAxis( + canvas: canvas, + size: size, + chartWidth: chartWidth, + candleAreaHeight: candleAreaHeight, + minPrice: minPrice, + maxPrice: maxPrice, + range: paddedRange, + candles: candles, + scale: scale, + panOffset: panOffset, + bottomPadding: bottomPadding, + theme: theme, + ); + + final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill; + final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill; + final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5; + final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5; + + final ema20Path = Path(); + final sma50Path = Path(); + final sma200Path = Path(); + final supertrendPath = Path(); + bool firstEma20 = true; + bool firstSma50 = true; + bool firstSma200 = true; + bool firstSupertrend = true; + + double getXForTime(DateTime t) { + if (candles.isEmpty) return 0.0; + final lastCandle = candles.last; + if (t.isAfter(lastCandle.timestamp) && candles.length > 1) { + final totalSpan = lastCandle.timestamp.difference(candles.first.timestamp).inSeconds; + final secPerCandle = totalSpan / (candles.length - 1); + if (secPerCandle > 0) { + final futureSecs = t.difference(lastCandle.timestamp).inSeconds; + final futureCandles = futureSecs / secPerCandle; + final lastDx = ((candles.length - 1) * totalCandleSpace) + panOffset + candleWidth / 2; + return lastDx + (futureCandles * totalCandleSpace); + } + } + + int bestIndex = 0; + int minDiff = 999999999; + for (int i = 0; i < candles.length; i++) { + final diff = candles[i].timestamp.difference(t).inSeconds.abs(); + if (diff < minDiff) { + minDiff = diff; + bestIndex = i; + } + } + return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2; + } + + double getYForPrice(double price) { + return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight; + } + + canvas.save(); + canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight)); + + for (int i = 0; i < candles.length; i++) { + final candle = candles[i]; + final isBullish = candle.close >= candle.open; + + final dx = (i * totalCandleSpace) + panOffset; + if (dx < -candleWidth || dx > chartWidth) continue; + + final yHigh = getYForPrice(candle.high); + final yLow = getYForPrice(candle.low); + final yOpen = getYForPrice(candle.open); + final yClose = getYForPrice(candle.close); + + canvas.drawLine( + Offset(dx + candleWidth / 2, yHigh), + Offset(dx + candleWidth / 2, yLow), + isBullish ? paintWickBullish : paintWickBearish, + ); + + final top = min(yOpen, yClose); + final bottom = max(yOpen, yClose); + final bodyHeight = max(bottom - top, 1.0); + + canvas.drawRect( + Rect.fromLTWH(dx, top, candleWidth, bodyHeight), + isBullish ? paintBullish : paintBearish, + ); + + final vHeight = (candle.volume / maxVolume) * volumeHeight; + final vTop = chartHeight - vHeight; + canvas.drawRect( + Rect.fromLTWH(dx, vTop, candleWidth, vHeight), + Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill, + ); + + if (indicators.isNotEmpty) { + final cx = dx + candleWidth / 2; + IndicatorModel? match; + for (var ind in indicators) { + if (ind.timestamp.isAtSameMomentAs(candle.timestamp) || ind.timestamp.difference(candle.timestamp).inHours.abs() < 12) { + match = ind; + break; + } + } + + if (match != null) { + if (showEma && match.ema20 != null) { + final y = getYForPrice(match.ema20!); + if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; } + else { ema20Path.lineTo(cx, y); } + } + if (showSma50 && match.sma50 != null) { + final y = getYForPrice(match.sma50!); + if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; } + else { sma50Path.lineTo(cx, y); } + } + if (showSma200 && match.sma200 != null) { + final y = getYForPrice(match.sma200!); + if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; } + else { sma200Path.lineTo(cx, y); } + } + + if (showSupertrend) { + final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper; + if (stVal != null) { + final y = getYForPrice(stVal); + if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; } + else { supertrendPath.lineTo(cx, y); } + } + } + } + } + } + + if (showEma && !firstEma20) { + canvas.drawPath(ema20Path, Paint()..color = 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.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0); + } + if (showSupertrend && !firstSupertrend) { + canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0); + } + + if (showPatterns) { + ChartOverlayRenderer.drawPatterns( + canvas: canvas, + patterns: patterns, + getX: getXForTime, + getY: getYForPrice, + ); + ChartOverlayRenderer.drawFutureProjectionZone( + canvas: canvas, + candles: candles, + patterns: patterns, + chartWidth: chartWidth, + candleAreaHeight: candleAreaHeight, + getX: getXForTime, + getY: getYForPrice, + theme: theme, + ); + } + + if (showSignals) { + ChartOverlayRenderer.drawSignals( + canvas: canvas, + signals: signals, + getX: getXForTime, + getY: getYForPrice, + theme: theme, + ); + } + + if (tapPosition != null && tapPosition!.dx < chartWidth) { + ChartOverlayRenderer.drawCrosshair( + canvas: canvas, + tapPosition: tapPosition!, + chartWidth: chartWidth, + chartHeight: chartHeight, + theme: theme, + ); + } + + canvas.restore(); + } + + @override + bool shouldRepaint(covariant CandlestickPainter oldDelegate) { + return oldDelegate.scale != scale || + oldDelegate.panOffset != panOffset || + oldDelegate.candles != candles || + oldDelegate.patterns != patterns || + oldDelegate.signals != signals || + oldDelegate.indicators != indicators || + oldDelegate.tapPosition != tapPosition; + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/chart/chart_overlay_renderer.dart b/FinlyticApp/lib/features/asset_detail/widgets/chart/chart_overlay_renderer.dart new file mode 100644 index 0000000..e74f4b8 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/chart/chart_overlay_renderer.dart @@ -0,0 +1,331 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../models/technical_analysis_model.dart'; +import '../../utils/pattern_explanations.dart'; + +/// Helper for rendering chart overlays: grid, axes, patterns, future projections, and signals. +class ChartOverlayRenderer { + static void drawGridAndAxis({ + required Canvas canvas, + required Size size, + required double chartWidth, + required double candleAreaHeight, + required double minPrice, + required double maxPrice, + required double range, + required List candles, + required double scale, + required double panOffset, + required double bottomPadding, + required ThemePreset theme, + }) { + final gridPaint = Paint()..color = theme.glassBorder..strokeWidth = 1; + final textPainter = TextPainter(textDirection: TextDirection.ltr); + + const int gridLines = 5; + for (int i = 0; i <= gridLines; i++) { + final y = candleAreaHeight - (i / gridLines) * candleAreaHeight; + final price = minPrice + (i / gridLines) * range; + + canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint); + + textPainter.text = TextSpan( + text: price.toStringAsFixed(2), + style: TextStyle(color: theme.textMuted, fontSize: 11), + ); + textPainter.layout(); + textPainter.paint(canvas, Offset(chartWidth + 5, y - 6)); + } + + if (candles.isEmpty) return; + + const double baseWidth = 10.0; + const double spacing = 5.0; + final double candleWidth = baseWidth * scale; + final double totalCandleSpace = candleWidth + (spacing * scale); + + final int xSteps = (chartWidth / 80).floor(); + if (xSteps <= 0) return; + + for (int i = 1; i < xSteps; i++) { + double x = i * (chartWidth / xSteps); + int candleIndex = ((x - panOffset) / totalCandleSpace).round(); + if (candleIndex >= 0 && candleIndex < candles.length) { + final t = candles[candleIndex].timestamp; + textPainter.text = TextSpan( + text: "${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}", + style: TextStyle(color: theme.textMuted, fontSize: 10), + ); + textPainter.layout(); + textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4)); + canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint); + } + } + } + + static void drawFutureProjectionZone({ + required Canvas canvas, + required List candles, + required List patterns, + required double chartWidth, + required double candleAreaHeight, + required double Function(DateTime) getX, + required double Function(double) getY, + required ThemePreset theme, + }) { + if (candles.isEmpty) return; + + final lastCandle = candles.last; + final double lastX = getX(lastCandle.timestamp); + + if (lastX < chartWidth - 10) { + final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight); + final futureBgPaint = Paint() + ..color = theme.primaryColor.withValues(alpha: 0.05) + ..style = PaintingStyle.fill; + canvas.drawRect(futureRect, futureBgPaint); + + final sepPaint = Paint() + ..color = theme.primaryColor.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawLine(Offset(lastX, 0), Offset(lastX, candleAreaHeight), sepPaint); + + final textPainter = TextPainter(textDirection: TextDirection.ltr); + textPainter.text = TextSpan( + text: 'PROGNOSE (KI & MUSTER)', + style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8), + ); + textPainter.layout(); + textPainter.paint(canvas, Offset(lastX + 8, 8)); + + for (var pattern in patterns) { + double targetPrice = 0.0; + if (pattern.breakoutSignal != null && pattern.breakoutSignal!.targetPrice > 0) { + targetPrice = pattern.breakoutSignal!.targetPrice; + } else if (pattern.lowerLine.isNotEmpty && pattern.upperLine.isNotEmpty) { + final diff = (pattern.upperLine.last.price - pattern.lowerLine.last.price).abs(); + targetPrice = lastCandle.close >= pattern.lowerLine.last.price + ? lastCandle.close + (diff > 0 ? diff : lastCandle.close * 0.05) + : lastCandle.close - (diff > 0 ? diff : lastCandle.close * 0.05); + } else if (pattern.upperLine.isNotEmpty) { + targetPrice = pattern.upperLine.last.price; + } else if (pattern.lowerLine.isNotEmpty) { + targetPrice = pattern.lowerLine.last.price; + } + + if (targetPrice > 0) { + final targetY = getY(targetPrice); + const int numSteps = 8; + final double availableWidth = max(chartWidth - lastX - 40, 60.0); + final double stepWidth = availableWidth / numSteps; + + final isBullish = targetPrice >= lastCandle.close; + final projColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed; + + double currX = lastX; + double currPrice = lastCandle.close; + final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps; + + for (int k = 1; k <= numSteps; k++) { + final nextX = lastX + (k * stepWidth); + final waveNoise = sin(k * 0.9) * (priceDeltaPerStep.abs() * 0.25); + final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise; + + final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.15; + final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.15; + + final yOpen = getY(currPrice); + final yClose = getY(nextPrice); + final yHigh = getY(highPrice); + final yLow = getY(lowPrice); + + final cWidth = max(stepWidth * 0.55, 3.0); + final cLeft = nextX - cWidth / 2; + + final isStepBullish = nextPrice >= currPrice; + final stepColor = isStepBullish ? AppTheme.primaryEmerald : AppTheme.accentRed; + + canvas.drawLine( + Offset(nextX, yHigh), + Offset(nextX, yLow), + Paint()..color = stepColor.withValues(alpha: 0.45)..strokeWidth = 1.0, + ); + + final top = min(yOpen, yClose); + final bottom = max(yOpen, yClose); + canvas.drawRect( + Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.5)), + Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill, + ); + + currX = nextX; + currPrice = nextPrice; + } + + final targetX = currX; + final pct = ((targetPrice - lastCandle.close) / lastCandle.close) * 100; + final pctSign = pct >= 0 ? '+' : ''; + final targetBadgePainter = TextPainter( + text: TextSpan( + text: ' ZIEL: ${targetPrice.toStringAsFixed(2)} € ($pctSign${pct.toStringAsFixed(1)}%) ', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + textDirection: TextDirection.ltr, + ); + targetBadgePainter.layout(); + + final badgeRect = RRect.fromLTRBR( + targetX - targetBadgePainter.width / 2, + targetY - targetBadgePainter.height / 2 - 3, + targetX + targetBadgePainter.width / 2, + targetY + targetBadgePainter.height / 2 + 3, + const Radius.circular(6), + ); + canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.92)); + targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2)); + } + } + } + } + + static void drawPatterns({ + required Canvas canvas, + required List patterns, + required double Function(DateTime) getX, + required double Function(double) getY, + }) { + for (var pattern in patterns) { + final color = PatternExplanations.getColorForPattern(pattern.type); + final paint = Paint()..color = color..style = PaintingStyle.stroke..strokeWidth = 2.5; + final fillPaint = Paint()..color = color.withValues(alpha: 0.12)..style = PaintingStyle.fill; + + Offset? firstPoint; + + void drawLine(List points) { + if (points.length < 2) return; + final path = Path(); + final startX = getX(points[0].time); + final startY = getY(points[0].price); + path.moveTo(startX, startY); + firstPoint ??= Offset(startX, startY); + + for (int i = 1; i < points.length; i++) { + final px = getX(points[i].time); + final py = getY(points[i].price); + path.lineTo(px, py); + } + canvas.drawPath(path, paint); + + for (var p in points) { + final px = getX(p.time); + final py = getY(p.price); + canvas.drawCircle(Offset(px, py), 4, Paint()..color = color); + canvas.drawCircle(Offset(px, py), 2, Paint()..color = Colors.white); + } + } + + if (pattern.upperLine.length >= 2 && pattern.lowerLine.length >= 2) { + final polyPath = Path(); + polyPath.moveTo(getX(pattern.upperLine[0].time), getY(pattern.upperLine[0].price)); + for (int i = 1; i < pattern.upperLine.length; i++) { + polyPath.lineTo(getX(pattern.upperLine[i].time), getY(pattern.upperLine[i].price)); + } + for (int i = pattern.lowerLine.length - 1; i >= 0; i--) { + polyPath.lineTo(getX(pattern.lowerLine[i].time), getY(pattern.lowerLine[i].price)); + } + polyPath.close(); + canvas.drawPath(polyPath, fillPaint); + } + + drawLine(pattern.upperLine); + drawLine(pattern.lowerLine); + + if (firstPoint != null) { + final label = PatternExplanations.getGermanName(pattern.type); + final textPainter = TextPainter( + text: TextSpan( + text: ' $label ', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final badgeX = firstPoint!.dx; + final badgeY = firstPoint!.dy - 18; + final badgeRect = RRect.fromLTRBR( + badgeX, + badgeY, + badgeX + textPainter.width + 4, + badgeY + textPainter.height + 4, + const Radius.circular(4), + ); + canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.85)); + textPainter.paint(canvas, Offset(badgeX + 2, badgeY + 2)); + } + } + } + + static void drawSignals({ + required Canvas canvas, + required List signals, + required double Function(DateTime) getX, + required double Function(double) getY, + required ThemePreset theme, + }) { + for (var signal in signals) { + final x = getX(signal.date); + final y = getY(signal.price); + + final isBuy = signal.type.toUpperCase() == 'BUY'; + final isSell = signal.type.toUpperCase() == 'SELL'; + if (!isBuy && !isSell) continue; + + final color = isBuy ? theme.primaryColor : theme.accentRed; + final label = isBuy ? '▲ BUY' : '▼ SELL'; + + final textPainter = TextPainter( + text: TextSpan(text: label, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + + final badgeWidth = textPainter.width + 12; + final badgeHeight = textPainter.height + 6; + final badgeY = isBuy ? y + 12 : y - badgeHeight - 12; + final badgeRect = RRect.fromLTRBR( + x - badgeWidth / 2, + badgeY, + x + badgeWidth / 2, + badgeY + badgeHeight, + const Radius.circular(10), + ); + + canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95)); + canvas.drawLine( + Offset(x, y), + Offset(x, isBuy ? badgeY : badgeY + badgeHeight), + Paint()..color = color..strokeWidth = 1.5, + ); + textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3)); + } + } + + static void drawCrosshair({ + required Canvas canvas, + required Offset tapPosition, + required double chartWidth, + required double chartHeight, + required ThemePreset theme, + }) { + final paint = Paint() + ..color = theme.textMuted.withValues(alpha: 0.5) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + canvas.drawLine(Offset(tapPosition.dx, 0), Offset(tapPosition.dx, chartHeight), paint); + if (tapPosition.dy <= chartHeight) { + canvas.drawLine(Offset(0, tapPosition.dy), Offset(chartWidth, tapPosition.dy), paint); + } + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/technical/indicator_ribbon_bar.dart b/FinlyticApp/lib/features/asset_detail/widgets/technical/indicator_ribbon_bar.dart new file mode 100644 index 0000000..533be9b --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/technical/indicator_ribbon_bar.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/glass_container.dart'; +import '../../utils/metric_explanations.dart'; + +class IndicatorRibbonBar extends StatelessWidget { + final bool showSma50; + final bool showSma200; + final bool showEma; + final bool showSupertrend; + final bool showPatterns; + final bool showSignals; + final ValueChanged onToggleSma50; + final ValueChanged onToggleSma200; + final ValueChanged onToggleEma; + final ValueChanged onToggleSupertrend; + final ValueChanged onTogglePatterns; + final ValueChanged onToggleSignals; + final VoidCallback? onToggleFullscreen; + final bool isFullscreen; + + const IndicatorRibbonBar({ + super.key, + required this.showSma50, + required this.showSma200, + required this.showEma, + required this.showSupertrend, + required this.showPatterns, + required this.showSignals, + required this.onToggleSma50, + required this.onToggleSma200, + required this.onToggleEma, + required this.onToggleSupertrend, + required this.onTogglePatterns, + required this.onToggleSignals, + this.onToggleFullscreen, + this.isFullscreen = false, + }); + + Widget _buildChip(BuildContext context, String label, bool isSelected, ValueChanged onChanged, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilterChip( + selected: isSelected, + label: Text( + label, + style: TextStyle( + color: isSelected ? Colors.black : color, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + selectedColor: color, + backgroundColor: color.withValues(alpha: 0.15), + side: BorderSide(color: color.withValues(alpha: 0.4)), + showCheckmark: false, + onSelected: onChanged, + ), + InkWell( + onTap: () => MetricExplanations.show(context, label), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return GlassContainer( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildChip(context, 'SMA 50', showSma50, onToggleSma50, AppTheme.accentCyan), + const SizedBox(width: 8), + _buildChip(context, 'SMA 200', showSma200, onToggleSma200, Colors.amber), + const SizedBox(width: 8), + _buildChip(context, 'EMA 20', showEma, onToggleEma, Colors.purpleAccent), + const SizedBox(width: 8), + _buildChip(context, 'Supertrend', showSupertrend, onToggleSupertrend, AppTheme.primaryEmerald), + const SizedBox(width: 8), + _buildChip(context, 'Muster', showPatterns, onTogglePatterns, Colors.orangeAccent), + const SizedBox(width: 8), + _buildChip(context, 'Signale', showSignals, onToggleSignals, Colors.greenAccent), + if (onToggleFullscreen != null) ...[ + const SizedBox(width: 12), + Container(width: 1, height: 20, color: AppTheme.activePreset.glassBorder), + const SizedBox(width: 12), + InkWell( + onTap: onToggleFullscreen, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.activePreset.cardSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.activePreset.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + isFullscreen ? 'Normal' : 'Vollbild', + style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/technical/pattern_card_item.dart b/FinlyticApp/lib/features/asset_detail/widgets/technical/pattern_card_item.dart new file mode 100644 index 0000000..c99ba20 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/technical/pattern_card_item.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/glass_container.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../../models/technical_analysis_model.dart'; +import '../../utils/pattern_explanations.dart'; + +class PatternCardItem extends StatelessWidget { + final ChartPatternModel pattern; + final int index; + final bool isEnabled; + final ValueChanged onToggle; + + const PatternCardItem({ + super.key, + required this.pattern, + required this.index, + required this.isEnabled, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + 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), + child: GlassContainer( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Checkbox( + value: isEnabled, + activeColor: patternColor, + checkColor: Colors.black, + side: BorderSide(color: patternColor.withValues(alpha: 0.6)), + onChanged: (bool? val) => onToggle(val ?? false), + ), + Expanded( + child: InkWell( + onTap: () => PatternExplanations.showPatternDetails(context, pattern.type), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isEnabled ? patternColor.withValues(alpha: 0.15) : AppTheme.glassSurface, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.polyline_outlined, + color: isEnabled ? patternColor : AppTheme.textMuted, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + pattern.type, + style: TextStyle( + fontWeight: FontWeight.bold, + color: isEnabled ? Colors.white : AppTheme.textMuted, + fontSize: 14, + decoration: isEnabled ? null : TextDecoration.lineThrough, + ), + ), + const SizedBox(width: 6), + Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted), + ], + ), + Text( + '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 ? patternColor : AppTheme.textMuted, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/widgets/technical/signal_card_item.dart b/FinlyticApp/lib/features/asset_detail/widgets/technical/signal_card_item.dart new file mode 100644 index 0000000..38a57e1 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/widgets/technical/signal_card_item.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/glass_container.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../../models/technical_analysis_model.dart'; + +class SignalCardItem extends StatelessWidget { + final StrategySignalModel signal; + + const SignalCardItem({super.key, required this.signal}); + + @override + Widget build(BuildContext context) { + final isBuy = signal.type.toUpperCase() == 'BUY'; + final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GlassContainer( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(isBuy ? Icons.north_east : Icons.south_east, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + signal.type.toUpperCase(), + style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 14), + ), + const SizedBox(width: 8), + Text( + '@ €${signal.price.toStringAsFixed(2)}', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), + ), + ], + ), + ), + StatusBadge(label: 'SIGNAL', color: color), + ], + ), + ), + ); + } +}