Files
focusflow/lib/core/widgets/streak_ring.dart
Oracle Public Cloud User 50931d839d Initial scaffold: FocusFlow ADHD Task Manager Flutter app
BLoC/Cubit state management, ADHD-friendly theme (calming teal, no red),
GetIt DI, GoRouter navigation. Screens: task dashboard, focus mode,
task create/detail, streaks, time perception, settings, onboarding, auth.
Custom widgets: TaskCard, RewardPopup, StreakRing, GentleNudgeCard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:53:58 +00:00

180 lines
5.0 KiB
Dart

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;
}