160 lines
4.7 KiB
Dart
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;
|
|
}
|