feat(App): update Finlytic Flutter app UI and blocs
This commit is contained in:
@@ -0,0 +1,799 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.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'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 showSignals;
|
||||
final bool showSupertrend;
|
||||
|
||||
const CandlestickChart({
|
||||
super.key,
|
||||
required this.candles,
|
||||
this.patterns = const [],
|
||||
this.signals = const [],
|
||||
this.indicators = const [],
|
||||
this.showPatterns = true,
|
||||
this.showSma50 = true,
|
||||
this.showSma200 = true,
|
||||
this.showEma = true,
|
||||
this.showSignals = true,
|
||||
this.showSupertrend = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CandlestickChart> createState() => _CandlestickChartState();
|
||||
}
|
||||
|
||||
class _CandlestickChartState extends State<CandlestickChart> {
|
||||
double _scale = 1.0;
|
||||
double _panOffset = 0.0;
|
||||
|
||||
Offset? _tapPosition;
|
||||
CandleModel? _selectedCandle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.candles.isEmpty) {
|
||||
return const Center(child: Text('No chart data'));
|
||||
}
|
||||
|
||||
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(
|
||||
onPointerSignal: (pointerSignal) {
|
||||
if (pointerSignal is PointerScrollEvent) {
|
||||
setState(() {
|
||||
final double zoomFactor = pointerSignal.scrollDelta.dy > 0 ? 0.9 : 1.1;
|
||||
_scale = (_scale * zoomFactor).clamp(0.2, 5.0);
|
||||
});
|
||||
}
|
||||
},
|
||||
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(
|
||||
size: Size.infinite,
|
||||
painter: _CandlePainter(
|
||||
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),
|
||||
// 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(Offset pos, double width) {
|
||||
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 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 candle = _selectedCandle!;
|
||||
final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.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(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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 = theme.primaryColor..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.purpleAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
|
||||
}
|
||||
if (showSupertrend && !firstSupertrend) {
|
||||
canvas.drawPath(supertrendPath, Paint()..color = Colors.lightBlueAccent..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) {
|
||||
final paint = Paint()
|
||||
..color = Colors.orangeAccent
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
for (var pattern in patterns) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/widgets/asset_logo_widget.dart';
|
||||
import '../../../../shared/widgets/favorite_star_button.dart';
|
||||
import '../../bloc/header/asset_header_bloc.dart';
|
||||
import '../../bloc/header/asset_header_state.dart';
|
||||
import '../../models/asset_model.dart';
|
||||
|
||||
class AssetHeroHeader extends StatelessWidget {
|
||||
final String symbol;
|
||||
final void Function(String exchange, String ticker)? onExchangeChanged;
|
||||
final VoidCallback? onForceRefresh;
|
||||
final String? selectedExchange;
|
||||
|
||||
const AssetHeroHeader({
|
||||
super.key,
|
||||
required this.symbol,
|
||||
this.onExchangeChanged,
|
||||
this.onForceRefresh,
|
||||
this.selectedExchange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.activePreset;
|
||||
|
||||
return BlocBuilder<AssetHeaderBloc, AssetHeaderState>(
|
||||
builder: (context, state) {
|
||||
String name = symbol;
|
||||
double? price;
|
||||
String currency = 'EUR';
|
||||
String currentExchange = selectedExchange ?? 'XETRA';
|
||||
List<AssetTickerOption> tickerOptions = [
|
||||
AssetTickerOption(ticker: 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: 0.0)
|
||||
];
|
||||
|
||||
AssetModel? asset;
|
||||
if (state is AssetHeaderLoaded) {
|
||||
asset = state.data;
|
||||
} else if (state is AssetHeaderLoading) {
|
||||
asset = state.previousData;
|
||||
}
|
||||
|
||||
if (asset != null) {
|
||||
name = asset.name.isNotEmpty ? asset.name : symbol;
|
||||
currency = asset.currency.isNotEmpty ? asset.currency : 'EUR';
|
||||
price = asset.currentPrice;
|
||||
currentExchange = selectedExchange ?? asset.exchange;
|
||||
|
||||
if (asset.tickers.isNotEmpty) {
|
||||
tickerOptions = asset.tickers;
|
||||
}
|
||||
}
|
||||
|
||||
final selectedOption = tickerOptions.firstWhere(
|
||||
(t) => t.exchange == currentExchange,
|
||||
orElse: () => tickerOptions.first,
|
||||
);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardSurface,
|
||||
border: Border(bottom: BorderSide(color: theme.glassBorder)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (Navigator.canPop(context)) ...[
|
||||
IconButton(
|
||||
tooltip: 'Zurück',
|
||||
icon: Icon(Icons.arrow_back, color: theme.textPrimary),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
AssetLogoWidget(symbolOrName: symbol, size: 48),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: theme.textPrimary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SelectableText(
|
||||
symbol,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryColor,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Force Refresh Data',
|
||||
icon: Icon(Icons.refresh, color: theme.primaryColor),
|
||||
onPressed: onForceRefresh,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FavoriteStarButton(symbol: symbol, identifier: symbol, name: name),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LIVE PRICE',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryColor,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
SelectableText(
|
||||
price != null && price > 0 ? price.toStringAsFixed(2) : '---',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
selectedOption.tradingCurrency.isNotEmpty ? selectedOption.tradingCurrency : currency,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Interactive Ticker & Exchange Selector Dropdown
|
||||
PopupMenuButton<String>(
|
||||
initialValue: selectedOption.exchange,
|
||||
tooltip: 'Select Exchange & Ticker',
|
||||
onSelected: (newExchange) {
|
||||
if (onExchangeChanged != null) {
|
||||
final opt = tickerOptions.firstWhere(
|
||||
(t) => t.exchange == newExchange,
|
||||
orElse: () => tickerOptions.first,
|
||||
);
|
||||
onExchangeChanged!(newExchange, opt.ticker);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
return tickerOptions.map((opt) {
|
||||
final ex = opt.exchange;
|
||||
final tick = opt.ticker;
|
||||
final label = '$tick ($ex)';
|
||||
final isSelected = ex == currentExchange;
|
||||
|
||||
return PopupMenuItem<String>(
|
||||
value: ex,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.business,
|
||||
size: 16,
|
||||
color: isSelected ? theme.primaryColor : theme.textMuted,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? theme.primaryColor : theme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.accentColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: theme.accentColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.business, size: 14, color: theme.accentColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${selectedOption.ticker} (${selectedOption.exchange})',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.arrow_drop_down, size: 16, color: theme.accentColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Modal dialog explaining key financial metric formulas and trading significance.
|
||||
class MetricExplanationModal extends StatelessWidget {
|
||||
final String title;
|
||||
final String formula;
|
||||
final String description;
|
||||
final String tradingSignificance;
|
||||
|
||||
const MetricExplanationModal({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.formula,
|
||||
required this.description,
|
||||
required this.tradingSignificance,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Kennzahl: $title'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Formel:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(formula, style: const TextStyle(fontFamily: 'monospace', color: Colors.cyanAccent)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Erklärung:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(description, style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Bedeutung für Trading & Bewertung:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(tradingSignificance, style: const TextStyle(fontSize: 13, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Schließen')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
|
||||
class CandleData {
|
||||
final DateTime time;
|
||||
final double open;
|
||||
final double high;
|
||||
final double low;
|
||||
final double close;
|
||||
|
||||
CandleData({
|
||||
required this.time,
|
||||
required this.open,
|
||||
required this.high,
|
||||
required this.low,
|
||||
required this.close,
|
||||
});
|
||||
|
||||
factory CandleData.fromJson(Map<String, dynamic> json) {
|
||||
return CandleData(
|
||||
time: json['timestamp'] != null ? DateTime.parse(json['timestamp'].toString()) : DateTime.now(),
|
||||
open: (json['open'] as num? ?? 0.0).toDouble(),
|
||||
high: (json['high'] as num? ?? 0.0).toDouble(),
|
||||
low: (json['low'] as num? ?? 0.0).toDouble(),
|
||||
close: (json['close'] as num? ?? 0.0).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CandleChartWidget extends StatelessWidget {
|
||||
final List<CandleData> candles;
|
||||
final double? supportLevel;
|
||||
final double? resistanceLevel;
|
||||
|
||||
const CandleChartWidget({
|
||||
super.key,
|
||||
this.candles = const [],
|
||||
this.supportLevel,
|
||||
this.resistanceLevel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (candles.isEmpty) {
|
||||
return Container(
|
||||
color: AppTheme.cardSurface,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.show_chart, color: AppTheme.textMuted, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Keine Candlestick-Daten verfgbar',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: CustomPaint(
|
||||
painter: _CandlePainter(
|
||||
candles: candles,
|
||||
supportLevel: supportLevel,
|
||||
resistanceLevel: resistanceLevel,
|
||||
),
|
||||
child: Container(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CandlePainter extends CustomPainter {
|
||||
final List<CandleData> candles;
|
||||
final double? supportLevel;
|
||||
final double? resistanceLevel;
|
||||
|
||||
_CandlePainter({
|
||||
required this.candles,
|
||||
this.supportLevel,
|
||||
this.resistanceLevel,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (candles.isEmpty) return;
|
||||
|
||||
double minPrice = candles.first.low;
|
||||
double maxPrice = candles.first.high;
|
||||
for (var c in candles) {
|
||||
if (c.low < minPrice) minPrice = c.low;
|
||||
if (c.high > maxPrice) maxPrice = c.high;
|
||||
}
|
||||
|
||||
if (supportLevel != null && supportLevel! < minPrice) minPrice = supportLevel!;
|
||||
if (resistanceLevel != null && resistanceLevel! > maxPrice) maxPrice = resistanceLevel!;
|
||||
|
||||
final priceRange = (maxPrice - minPrice) == 0 ? 1.0 : (maxPrice - minPrice);
|
||||
final padding = size.height * 0.05;
|
||||
final usableHeight = size.height - (padding * 2);
|
||||
|
||||
double getY(double price) {
|
||||
final normalized = (price - minPrice) / priceRange;
|
||||
return size.height - padding - (normalized * usableHeight);
|
||||
}
|
||||
|
||||
// Gridlines
|
||||
final gridPaint = Paint()
|
||||
..color = Colors.white10
|
||||
..strokeWidth = 1;
|
||||
|
||||
for (int i = 1; i <= 4; i++) {
|
||||
final y = size.height * (i / 5);
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
|
||||
}
|
||||
|
||||
// Support Line
|
||||
if (supportLevel != null) {
|
||||
final supPaint = Paint()
|
||||
..color = AppTheme.primaryEmerald.withValues(alpha: 0.6)
|
||||
..strokeWidth = 1.5
|
||||
..style = PaintingStyle.stroke;
|
||||
final y = getY(supportLevel!);
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), supPaint);
|
||||
}
|
||||
|
||||
// Resistance Line
|
||||
if (resistanceLevel != null) {
|
||||
final resPaint = Paint()
|
||||
..color = AppTheme.accentRed.withValues(alpha: 0.6)
|
||||
..strokeWidth = 1.5
|
||||
..style = PaintingStyle.stroke;
|
||||
final y = getY(resistanceLevel!);
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), resPaint);
|
||||
}
|
||||
|
||||
// Candlesticks
|
||||
final candleWidth = (size.width / candles.length) * 0.7;
|
||||
final candleSpacing = size.width / candles.length;
|
||||
|
||||
for (int i = 0; i < candles.length; i++) {
|
||||
final candle = candles[i];
|
||||
final x = (i * candleSpacing) + (candleSpacing / 2);
|
||||
final isBullish = candle.close >= candle.open;
|
||||
final candleColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
final wickPaint = Paint()
|
||||
..color = candleColor
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
final highY = getY(candle.high);
|
||||
final lowY = getY(candle.low);
|
||||
canvas.drawLine(Offset(x, highY), Offset(x, lowY), wickPaint);
|
||||
|
||||
final openY = getY(candle.open);
|
||||
final closeY = getY(candle.close);
|
||||
final topY = openY < closeY ? openY : closeY;
|
||||
final bodyHeight = (openY - closeY).abs();
|
||||
|
||||
final bodyPaint = Paint()
|
||||
..color = candleColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(
|
||||
x - (candleWidth / 2),
|
||||
topY,
|
||||
candleWidth,
|
||||
bodyHeight < 1 ? 1 : bodyHeight,
|
||||
),
|
||||
bodyPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _CandlePainter oldDelegate) => true;
|
||||
}
|
||||
Reference in New Issue
Block a user