/// Simple line chart of unit prices over time. library; import 'package:flutter/material.dart'; import '../models/purchase.dart'; import '../utils/dates.dart'; import '../utils/money.dart'; class PriceChart extends StatelessWidget { const PriceChart({super.key, required this.purchases}); final List purchases; @override Widget build(BuildContext context) { if (purchases.isEmpty) { return const SizedBox.shrink(); } final points = purchases.reversed.toList(); final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: 160, child: CustomPaint( painter: _PriceChartPainter( prices: [ for (final purchase in points) purchase.unitOrLinePrice, ], lineColor: theme.colorScheme.primary, fillColor: theme.colorScheme.primary.withValues(alpha: 0.12), gridColor: theme.colorScheme.outlineVariant, labelColor: theme.colorScheme.onSurfaceVariant, ), child: const SizedBox.expand(), ), ), const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( formatDate(points.first.boughtAt), style: theme.textTheme.labelMedium, ), Text( formatDate(points.last.boughtAt), style: theme.textTheme.labelMedium, ), ], ), const SizedBox(height: 4), Text( 'Latest ${formatEuro(points.last.unitOrLinePrice)}', style: theme.textTheme.labelMedium, ), ], ); } } class _PriceChartPainter extends CustomPainter { _PriceChartPainter({ required this.prices, required this.lineColor, required this.fillColor, required this.gridColor, required this.labelColor, }); final List prices; final Color lineColor; final Color fillColor; final Color gridColor; final Color labelColor; @override void paint(Canvas canvas, Size size) { final minPrice = prices.reduce((a, b) => a < b ? a : b); final maxPrice = prices.reduce((a, b) => a > b ? a : b); final span = (maxPrice - minPrice).abs() < 0.01 ? 1.0 : maxPrice - minPrice; const left = 36.0; const right = 8.0; const top = 12.0; const bottom = 8.0; final chart = Rect.fromLTRB(left, top, size.width - right, size.height - bottom); final gridPaint = Paint() ..color = gridColor ..strokeWidth = 1; for (var i = 0; i < 3; i++) { final y = chart.top + chart.height * i / 2; canvas.drawLine(Offset(chart.left, y), Offset(chart.right, y), gridPaint); } final path = Path(); final fill = Path(); Offset? last; for (var i = 0; i < prices.length; i++) { final x = prices.length == 1 ? chart.center.dx : chart.left + chart.width * i / (prices.length - 1); final y = chart.bottom - chart.height * ((prices[i] - minPrice) / span); final point = Offset(x, y); last = point; if (i == 0) { path.moveTo(x, y); fill.moveTo(x, chart.bottom); fill.lineTo(x, y); } else { path.lineTo(x, y); fill.lineTo(x, y); } } if (last != null) { fill.lineTo(last.dx, chart.bottom); fill.close(); } canvas.drawPath(fill, Paint()..color = fillColor); canvas.drawPath( path, Paint() ..color = lineColor ..style = PaintingStyle.stroke ..strokeWidth = 2.5 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round, ); final dot = Paint()..color = lineColor; for (var i = 0; i < prices.length; i++) { final x = prices.length == 1 ? chart.center.dx : chart.left + chart.width * i / (prices.length - 1); final y = chart.bottom - chart.height * ((prices[i] - minPrice) / span); canvas.drawCircle(Offset(x, y), 3.5, dot); } final labels = [maxPrice, (minPrice + maxPrice) / 2, minPrice]; final textStyle = TextStyle(color: labelColor, fontSize: 10); for (var i = 0; i < labels.length; i++) { final tp = TextPainter( text: TextSpan(text: labels[i].toStringAsFixed(2), style: textStyle), textDirection: TextDirection.ltr, )..layout(maxWidth: left - 4); final y = chart.top + chart.height * i / 2 - tp.height / 2; tp.paint(canvas, Offset(0, y)); } } @override bool shouldRepaint(covariant _PriceChartPainter oldDelegate) => oldDelegate.prices != prices || oldDelegate.lineColor != lineColor; }