Created
April 28, 2025 11:22
-
-
Save imaNNeo/22cbf0b39f6e793fc4a06a8c2f83182a to your computer and use it in GitHub Desktop.
OHLC chart (using a custom painter in CandlestickChart)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:equatable/equatable.dart'; | |
import 'package:fl_chart/fl_chart.dart'; | |
import 'package:fl_chart_app/presentation/resources/app_colors.dart'; | |
import 'package:fl_chart_app/util/csv_parser.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
class CandlestickChartSample1 extends StatefulWidget { | |
const CandlestickChartSample1({super.key}); | |
@override | |
State<StatefulWidget> createState() => CandlestickChartSample1State(); | |
} | |
class CandlestickChartSample1State extends State<CandlestickChartSample1> { | |
List<List<_BtcCandlestickData>>? _btcMonthlyData; | |
int _currentMonthIndex = 0; | |
late final List<String> monthsNames; | |
final int minDays = 1; | |
final int maxDays = 31; | |
late final FlLine _gridLine; | |
@override | |
void initState() { | |
monthsNames = [ | |
'January', | |
'February', | |
'March', | |
'April', | |
'May', | |
'June', | |
'July', | |
'August', | |
'September', | |
'October', | |
'November', | |
'December', | |
]; | |
_loadData(); | |
_gridLine = FlLine( | |
color: Colors.blueGrey.withValues(alpha: 0.4), | |
strokeWidth: 0.4, | |
dashArray: [8, 4], | |
); | |
super.initState(); | |
} | |
void _loadData() async { | |
final data = await rootBundle | |
.loadString('assets/data/bitcoin_2023-01-01_2023-12-31.csv'); | |
final rows = CsvParser.parse(data); | |
if (!mounted) { | |
return; | |
} | |
setState(() { | |
final allData = rows.skip(1).map((row) { | |
// 2023-12-31,2024-01-01 | |
return _BtcCandlestickData( | |
datetime: DateTime.parse(row[0]), | |
open: double.parse(row[2]), | |
high: double.parse(row[3]), | |
low: double.parse(row[4]), | |
close: double.parse(row[5]), | |
volume: double.parse(row[6]), | |
marketCap: double.parse(row[7]), | |
); | |
}).toList(); | |
_btcMonthlyData = List.generate(12, (index) { | |
final month = index + 1; | |
final monthData = allData | |
.where((element) => element.datetime.month == month) | |
.toList(); | |
monthData.sort((a, b) => a.datetime.compareTo(b.datetime)); | |
return monthData; | |
}); | |
}); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Column( | |
children: [ | |
const SizedBox(height: 18), | |
const Row( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
Text( | |
'BTC Price 2024', | |
style: TextStyle( | |
color: AppColors.contentColorYellow, | |
fontSize: 20, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
], | |
), | |
const SizedBox(height: 18), | |
Row( | |
children: [ | |
Expanded( | |
child: Align( | |
alignment: Alignment.centerRight, | |
child: IconButton( | |
onPressed: _canGoPrevious ? _previousMonth : null, | |
icon: const Icon(Icons.navigate_before_rounded), | |
), | |
), | |
), | |
SizedBox( | |
width: 92, | |
child: Text( | |
monthsNames[_currentMonthIndex], | |
textAlign: TextAlign.center, | |
style: const TextStyle( | |
color: AppColors.contentColorWhite, | |
fontSize: 16, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
), | |
Expanded( | |
child: Align( | |
alignment: Alignment.centerLeft, | |
child: IconButton( | |
onPressed: _canGoNext ? _nextMonth : null, | |
icon: const Icon(Icons.navigate_next_rounded), | |
), | |
), | |
), | |
], | |
), | |
const SizedBox(height: 18), | |
AspectRatio( | |
aspectRatio: 1.5, | |
child: Stack( | |
children: [ | |
if (_btcMonthlyData != null) | |
Padding( | |
padding: const EdgeInsets.only( | |
top: 0.0, | |
right: 18.0, | |
), | |
child: CandlestickChart( | |
CandlestickChartData( | |
candlestickSpots: _btcMonthlyData![_currentMonthIndex] | |
.asMap() | |
.entries | |
.map((entry) { | |
final index = entry.key; | |
final data = entry.value; | |
return CandlestickSpot( | |
x: index.toDouble(), | |
open: data.open, | |
high: data.high, | |
low: data.low, | |
close: data.close, | |
); | |
}).toList(), | |
candlestickPainter: CandlestickOHLCPainter(), | |
minX: 0, | |
maxX: 31, | |
candlestickTouchData: CandlestickTouchData( | |
handleBuiltInTouches: false, | |
), | |
gridData: FlGridData( | |
show: true, | |
getDrawingHorizontalLine: (_) => _gridLine, | |
getDrawingVerticalLine: (_) => _gridLine, | |
), | |
titlesData: FlTitlesData( | |
show: true, | |
rightTitles: const AxisTitles( | |
sideTitles: SideTitles(showTitles: false), | |
), | |
topTitles: const AxisTitles( | |
sideTitles: SideTitles(showTitles: false), | |
), | |
leftTitles: AxisTitles( | |
drawBelowEverything: true, | |
sideTitles: SideTitles( | |
showTitles: true, | |
maxIncluded: false, | |
minIncluded: false, | |
reservedSize: 60, | |
getTitlesWidget: _leftTitles, | |
), | |
), | |
bottomTitles: AxisTitles( | |
axisNameWidget: Container( | |
margin: const EdgeInsets.only(bottom: 20), | |
child: const Text( | |
'Day of month', | |
style: TextStyle( | |
color: AppColors.contentColorGreen, | |
fontWeight: FontWeight.bold, | |
fontSize: 16, | |
), | |
), | |
), | |
axisNameSize: 40, | |
sideTitles: SideTitles( | |
showTitles: true, | |
reservedSize: 38, | |
maxIncluded: false, | |
interval: 1, | |
getTitlesWidget: _bottomTitles, | |
), | |
), | |
), | |
touchedPointIndicator: AxisSpotIndicator( | |
painter: AxisLinesIndicatorPainter( | |
verticalLineProvider: (x) { | |
final data = | |
_btcMonthlyData![_currentMonthIndex][x.toInt()]; | |
return VerticalLine( | |
x: x, | |
color: (data.isUp | |
? AppColors.contentColorGreen | |
: AppColors.contentColorRed) | |
.withValues(alpha: 0.5), | |
strokeWidth: 1, | |
); | |
}, | |
horizontalLineProvider: (y) => HorizontalLine( | |
y: y, | |
label: HorizontalLineLabel( | |
show: true, | |
style: const TextStyle( | |
color: AppColors.contentColorYellow, | |
fontSize: 12, | |
fontWeight: FontWeight.bold, | |
), | |
labelResolver: (hLine) => | |
hLine.y.toInt().toString(), | |
alignment: Alignment.topLeft), | |
color: AppColors.contentColorYellow.withValues( | |
alpha: 0.8, | |
), | |
strokeWidth: 1, | |
), | |
), | |
), | |
), | |
), | |
), | |
if (_btcMonthlyData == null) | |
const Center( | |
child: CircularProgressIndicator(), | |
) | |
], | |
), | |
), | |
], | |
); | |
} | |
bool get _canGoNext => _currentMonthIndex < 11; | |
bool get _canGoPrevious => _currentMonthIndex > 0; | |
void _previousMonth() { | |
if (!_canGoPrevious) { | |
return; | |
} | |
setState(() { | |
_currentMonthIndex--; | |
}); | |
} | |
void _nextMonth() { | |
if (!_canGoNext) { | |
return; | |
} | |
setState(() { | |
_currentMonthIndex++; | |
}); | |
} | |
Widget _bottomTitles(double value, TitleMeta meta) { | |
final day = value.toInt() + 1; | |
final isImportantToShow = day % 5 == 0 || day == 1; | |
if (!isImportantToShow) { | |
return const SizedBox(); | |
} | |
return SideTitleWidget( | |
meta: meta, | |
child: Text( | |
day.toString(), | |
style: const TextStyle( | |
color: AppColors.contentColorGreen, | |
fontSize: 12, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
); | |
} | |
Widget _leftTitles(double value, TitleMeta meta) { | |
return SideTitleWidget( | |
meta: meta, | |
child: Text( | |
meta.formattedValue, | |
style: const TextStyle( | |
color: AppColors.contentColorYellow, | |
fontSize: 16, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
); | |
} | |
} | |
class _BtcCandlestickData with EquatableMixin { | |
_BtcCandlestickData({ | |
required this.datetime, | |
required this.open, | |
required this.high, | |
required this.low, | |
required this.close, | |
required this.volume, | |
required this.marketCap, | |
}); | |
final DateTime datetime; | |
final double open; | |
final double high; | |
final double low; | |
final double close; | |
final double volume; | |
final double marketCap; | |
bool get isUp => open < close; | |
@override | |
List<Object?> get props => [ | |
datetime, | |
open, | |
high, | |
low, | |
close, | |
volume, | |
marketCap, | |
]; | |
} | |
class CandlestickOHLCPainter extends FlCandlestickPainter { | |
@override | |
void paint( | |
Canvas canvas, | |
ValueInCanvasProvider xInCanvasProvider, | |
ValueInCanvasProvider yInCanvasProvider, | |
CandlestickSpot spot, | |
int spotIndex, | |
) { | |
final xOffsetInCanvas = xInCanvasProvider(spot.x); | |
final openYOffsetInCanvas = yInCanvasProvider(spot.open); | |
final highYOffsetInCanvas = yInCanvasProvider(spot.high); | |
final lowOYOffsetInCanvas = yInCanvasProvider(spot.low); | |
final closeYOffsetInCanvas = yInCanvasProvider(spot.close); | |
final isUp = spot.open < spot.close; | |
// low to high | |
canvas.drawLine( | |
Offset(xOffsetInCanvas, lowOYOffsetInCanvas), | |
Offset(xOffsetInCanvas, highYOffsetInCanvas), | |
Paint() | |
..style = PaintingStyle.fill | |
..color = isUp ? Colors.green : Colors.red | |
..strokeWidth = 3, | |
); | |
const openCloseWidth = 5.0; | |
// open | |
canvas.drawLine( | |
Offset(xOffsetInCanvas, openYOffsetInCanvas), | |
Offset(xOffsetInCanvas - openCloseWidth, openYOffsetInCanvas), | |
Paint() | |
..style = PaintingStyle.fill | |
..color = isUp ? Colors.green : Colors.red | |
..strokeWidth = 2, | |
); | |
// close | |
canvas.drawLine( | |
Offset(xOffsetInCanvas, closeYOffsetInCanvas), | |
Offset(xOffsetInCanvas + openCloseWidth, closeYOffsetInCanvas), | |
Paint() | |
..style = PaintingStyle.fill | |
..color = isUp ? Colors.green : Colors.red | |
..strokeWidth = 2, | |
); | |
} | |
@override | |
FlCandlestickPainter lerp( | |
FlCandlestickPainter a, | |
FlCandlestickPainter b, | |
double t, | |
) { | |
if (a is! CandlestickOHLCPainter || b is! CandlestickOHLCPainter) { | |
return b; | |
} | |
return CandlestickOHLCPainter(); | |
} | |
@override | |
Color getMainColor({ | |
required CandlestickSpot spot, | |
required int spotIndex, | |
}) => | |
Colors.red; | |
@override | |
List<Object?> get props => []; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Related to:
imaNNeo/fl_chart#1143 and imaNNeo/fl_chart#1897
CleanShot.2025-04-28.at.13.19.39.mp4