307 lines
11 KiB
Dart
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)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|