import 'dart:math' as math; import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; /// Circular streak progress indicator. /// /// - Ring fills based on current count vs a goal. /// - Grace days shown as dashed segments. /// - Frozen indicator (snowflake icon). /// - Center shows current count. /// - Warm teal/green when active, gray when in grace period. class StreakRing extends StatelessWidget { /// Current streak count. final int currentCount; /// Number of grace days remaining (max grace days minus used). final int graceDaysRemaining; /// Total grace days allowed. final int totalGraceDays; /// Whether the streak is currently frozen. final bool isFrozen; /// Size of the widget (width & height). final double size; /// Optional label beneath the count. final String? label; const StreakRing({ super.key, required this.currentCount, this.graceDaysRemaining = 0, this.totalGraceDays = 2, this.isFrozen = false, this.size = 100, this.label, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return SizedBox( width: size, height: size, child: CustomPaint( painter: _StreakRingPainter( progress: _progress, graceProgress: _graceProgress, isFrozen: isFrozen, activeColor: AppColors.primary, graceColor: AppColors.skipped, frozenColor: AppColors.primaryLight, backgroundColor: theme.dividerColor.withAlpha(50), ), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (isFrozen) Icon(Icons.ac_unit_rounded, size: size * 0.22, color: AppColors.primaryLight) else Text( '$currentCount', style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w800, fontSize: size * 0.26, ), ), if (label != null) Text( label!, style: theme.textTheme.bodySmall?.copyWith(fontSize: size * 0.1), textAlign: TextAlign.center, ), ], ), ), ), ); } /// Normalised 0-1 progress for the main ring. /// We cap visual progress at 1.0 but the count can exceed 30. double get _progress => (currentCount / 30).clamp(0.0, 1.0); /// Grace segment progress (shown as dashed portion after the main ring). double get _graceProgress { if (totalGraceDays == 0) return 0; return ((totalGraceDays - graceDaysRemaining) / totalGraceDays).clamp(0.0, 1.0); } } class _StreakRingPainter extends CustomPainter { final double progress; final double graceProgress; final bool isFrozen; final Color activeColor; final Color graceColor; final Color frozenColor; final Color backgroundColor; _StreakRingPainter({ required this.progress, required this.graceProgress, required this.isFrozen, required this.activeColor, required this.graceColor, required this.frozenColor, required this.backgroundColor, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = (size.shortestSide / 2) - 6; const strokeWidth = 8.0; const startAngle = -math.pi / 2; // Background ring canvas.drawCircle( center, radius, Paint() ..color = backgroundColor ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round, ); // Main progress arc final sweepAngle = 2 * math.pi * progress; if (progress > 0) { canvas.drawArc( Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, Paint() ..color = isFrozen ? frozenColor : activeColor ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round, ); } // Grace dashed segments (small arcs after the progress) if (graceProgress > 0 && !isFrozen) { const graceArcLength = math.pi / 8; // each dash length final graceStart = startAngle + sweepAngle + 0.05; final dashCount = (graceProgress * 3).ceil().clamp(0, 3); for (int i = 0; i < dashCount; i++) { final dashStart = graceStart + i * (graceArcLength + 0.06); canvas.drawArc( Rect.fromCircle(center: center, radius: radius), dashStart, graceArcLength, false, Paint() ..color = graceColor ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth * 0.6 ..strokeCap = StrokeCap.round, ); } } } @override bool shouldRepaint(covariant _StreakRingPainter oldDelegate) => progress != oldDelegate.progress || graceProgress != oldDelegate.graceProgress || isFrozen != oldDelegate.isFrozen; }