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 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 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 = []; 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; } }