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.
258 lines
8.2 KiB
258 lines
8.2 KiB
import 'package:flutter/material.dart';
|
|
import 'package:practice_engine/practice_engine.dart';
|
|
|
|
/// A chart widget that visualizes learning progress over time.
|
|
/// Shows the percentage of questions that are still unknown (not yet learned).
|
|
/// As you learn more questions, this percentage decreases.
|
|
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 (take most recent, then reverse so oldest is first for chronological display)
|
|
final displayAttempts = attempts.reversed.take(maxDisplayItems).toList().reversed.toList();
|
|
if (displayAttempts.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
// Find min and max values for scaling (using unknown percentage)
|
|
final percentages = displayAttempts.map((e) => e.unknownPercentage).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].unknownPercentage;
|
|
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.unknownPercentage).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;
|
|
}
|
|
}
|
|
|
|
|