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>
180 lines
5.0 KiB
Dart
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;
|
|
}
|