You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
decky/lib/widgets/attempts_chart.dart

256 lines
8.0 KiB

import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
/// A chart widget that visualizes attempt progress over time.
class AttemptsChart extends StatelessWidget {
final List<AttemptHistoryEntry> attempts;
final int maxDisplayItems;
const AttemptsChart({
super.key,
required this.attempts,
this.maxDisplayItems = 15,
});
@override
Widget build(BuildContext context) {
if (attempts.isEmpty) {
return const SizedBox.shrink();
}
// Get recent attempts (most recent first, then reverse for display)
final displayAttempts = attempts.reversed.take(maxDisplayItems).toList();
if (displayAttempts.isEmpty) {
return const SizedBox.shrink();
}
// Find min and max values for scaling
final percentages = displayAttempts.map((e) => e.percentageCorrect).toList();
final minValue = percentages.reduce((a, b) => a < b ? a : b);
final maxValue = percentages.reduce((a, b) => a > b ? a : b);
final range = (maxValue - minValue).clamp(10.0, 100.0); // Ensure minimum range
final chartMin = (minValue - range * 0.1).clamp(0.0, 100.0);
final chartMax = (maxValue + range * 0.1).clamp(0.0, 100.0);
final chartRange = chartMax - chartMin;
return Container(
height: 220,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Y-axis labels
SizedBox(
width: 40,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${chartMax.toInt()}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
Text(
'${((chartMin + chartMax) / 2).toInt()}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
Text(
'${chartMin.toInt()}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
],
),
),
// Chart area
Expanded(
child: Column(
children: [
Expanded(
child: CustomPaint(
painter: _AttemptsChartPainter(
attempts: displayAttempts,
chartMin: chartMin,
chartMax: chartMax,
chartRange: chartRange,
colorScheme: Theme.of(context).colorScheme,
),
child: Container(),
),
),
const SizedBox(height: 8),
// X-axis labels (attempt numbers)
SizedBox(
height: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'1',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
if (displayAttempts.length > 1)
Text(
'${displayAttempts.length}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
],
),
),
],
),
),
],
),
);
}
}
class _AttemptsChartPainter extends CustomPainter {
final List<AttemptHistoryEntry> attempts;
final double chartMin;
final double chartMax;
final double chartRange;
final ColorScheme colorScheme;
_AttemptsChartPainter({
required this.attempts,
required this.chartMin,
required this.chartMax,
required this.chartRange,
required this.colorScheme,
});
@override
void paint(Canvas canvas, Size size) {
if (attempts.isEmpty) return;
final padding = 8.0;
final chartWidth = size.width - padding * 2;
final chartHeight = size.height - padding * 2;
final pointSpacing = attempts.length > 1
? chartWidth / (attempts.length - 1)
: chartWidth; // If only one point, center it
// Draw grid lines
_drawGridLines(canvas, size, padding, chartHeight);
// Draw line and points
final path = Path();
final points = <Offset>[];
for (int i = 0; i < attempts.length; i++) {
final percentage = attempts[i].percentageCorrect;
final normalizedValue = chartRange > 0
? ((percentage - chartMin) / chartRange).clamp(0.0, 1.0)
: 0.5; // If all values are the same, center vertically
final x = padding + (i * pointSpacing);
final y = padding + chartHeight - (normalizedValue * chartHeight);
final point = Offset(x, y);
points.add(point);
if (i == 0) {
path.moveTo(point.dx, point.dy);
} else {
path.lineTo(point.dx, point.dy);
}
}
// Draw the line
final linePaint = Paint()
..color = colorScheme.primary
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
canvas.drawPath(path, linePaint);
// Draw points
final pointPaint = Paint()
..color = colorScheme.primary
..style = PaintingStyle.fill;
final selectedPointPaint = Paint()
..color = colorScheme.primaryContainer
..style = PaintingStyle.fill;
for (int i = 0; i < points.length; i++) {
final point = points[i];
// Draw point circle
final isRecent = i >= points.length - 3; // Highlight last 3 attempts
canvas.drawCircle(
point,
isRecent ? 5 : 4,
isRecent ? selectedPointPaint : pointPaint,
);
// Draw outer ring for better visibility
final ringPaint = Paint()
..color = colorScheme.primary.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawCircle(point, isRecent ? 6 : 5, ringPaint);
}
// Draw average line (dashed)
final avgPercentage = attempts.map((e) => e.percentageCorrect).reduce((a, b) => a + b) / attempts.length;
final avgNormalized = ((avgPercentage - chartMin) / chartRange).clamp(0.0, 1.0);
final avgY = padding + chartHeight - (avgNormalized * chartHeight);
final avgLinePaint = Paint()
..color = colorScheme.secondary.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = 1;
// Draw dashed line manually
final dashLength = 5.0;
final gapLength = 5.0;
double currentX = padding;
while (currentX < size.width - padding) {
canvas.drawLine(
Offset(currentX, avgY),
Offset((currentX + dashLength).clamp(padding, size.width - padding), avgY),
avgLinePaint,
);
currentX += dashLength + gapLength;
}
}
void _drawGridLines(Canvas canvas, Size size, double padding, double chartHeight) {
final gridPaint = Paint()
..color = colorScheme.onSurface.withValues(alpha: 0.1)
..style = PaintingStyle.stroke
..strokeWidth = 1;
// Draw horizontal grid lines (0%, 25%, 50%, 75%, 100%)
for (int i = 0; i <= 4; i++) {
final value = chartMin + (chartRange * i / 4);
final normalized = ((value - chartMin) / chartRange).clamp(0.0, 1.0);
final y = padding + chartHeight - (normalized * chartHeight);
canvas.drawLine(
Offset(padding, y),
Offset(size.width - padding, y),
gridPaint,
);
}
}
@override
bool shouldRepaint(_AttemptsChartPainter oldDelegate) {
return oldDelegate.attempts != attempts ||
oldDelegate.chartMin != chartMin ||
oldDelegate.chartMax != chartMax;
}
}

Powered by TurnKey Linux.