import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../../../core/theme/app_theme.dart'; import '../../models/technical_analysis_model.dart'; import 'candlestick_painter.dart'; 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 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, required this.candles, this.patterns = const [], this.signals = const [], this.indicators = const [], 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 State createState() => _CandlestickChartState(); } 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 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 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) { 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: 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: CandlestickPainter( candles: widget.candles, patterns: widget.patterns, signals: widget.signals, indicators: widget.indicators, scale: _scale, panOffset: _panOffset, theme: theme, showPatterns: widget.showPatterns, showSma50: widget.showSma50, showSma200: widget.showSma200, showEma: widget.showEma, showSignals: widget.showSignals, showSupertrend: widget.showSupertrend, tapPosition: _tapPosition, ), ), if (_selectedCandle != null) _buildTooltip(theme), _buildZoomControls(theme), ], ), ), ), ), ), ); } 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; 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) { setState(() { _tapPosition = pos; _selectedCandle = widget.candles[index]; }); } } Widget _buildTooltip(ThemePreset theme) { 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, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: theme.cardSurface.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8), border: Border.all(color: theme.glassBorder), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(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)), ], ), ), ); } }