Files
Finlytic/FinlyticApp/lib/features/asset_detail/widgets/chart/candlestick_chart.dart
T

307 lines
11 KiB
Dart

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<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> 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<CandlestickChart> createState() => _CandlestickChartState();
}
class _CandlestickChartState extends State<CandlestickChart> {
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)),
],
),
),
);
}
}