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>
This commit is contained in:
179
lib/core/widgets/streak_ring.dart
Normal file
179
lib/core/widgets/streak_ring.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user