feat(chart): interactive zoom/pan controls, pattern overlays, predictions, individual pattern filtering and landscape fullscreen mode

This commit is contained in:
2026-08-15 01:03:57 +02:00
parent 1ccb6b613f
commit 5a6a50a609
12 changed files with 1501 additions and 1165 deletions
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble());
}
class ChartPatternModel {
final String type;
final List<PatternPoint> upperLine;
final List<PatternPoint> lowerLine;
ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine});
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
return ChartPatternModel(
type: json['type'] ?? '',
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).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<String, dynamic> 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<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> 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<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
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<CandlestickChart> {
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<CandlestickChart> {
}
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<CandlestickChart> {
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<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> 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<PatternPoint> 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;
}
}