ok
This commit is contained in:
@@ -5,6 +5,7 @@ class AnswerOption extends StatelessWidget {
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final bool? isCorrect;
|
||||
final bool isMultipleChoice;
|
||||
|
||||
const AnswerOption({
|
||||
super.key,
|
||||
@@ -12,6 +13,7 @@ class AnswerOption extends StatelessWidget {
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.isCorrect,
|
||||
this.isMultipleChoice = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -63,12 +65,12 @@ class AnswerOption extends StatelessWidget {
|
||||
),
|
||||
if (isSelected && icon == null)
|
||||
Icon(
|
||||
Icons.radio_button_checked,
|
||||
isMultipleChoice ? Icons.check_box : Icons.radio_button_checked,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
else if (!isSelected)
|
||||
Icon(
|
||||
Icons.radio_button_unchecked,
|
||||
isMultipleChoice ? Icons.check_box_outline_blank : Icons.radio_button_unchecked,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user