Files
receipity/lib/widgets/price_chart.dart
T

160 lines
4.7 KiB
Dart

/// 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<Purchase> 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<double> 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;
}