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>
126 lines
4.0 KiB
Dart
126 lines
4.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../theme/app_colors.dart';
|
|
|
|
/// Visual time block widget showing estimated vs actual duration.
|
|
///
|
|
/// - Horizontal bar: green (under estimate) -> yellow (near) -> soft orange (over).
|
|
/// - NO red — going over is shown gently, not punitively.
|
|
class TimeVisualizer extends StatelessWidget {
|
|
/// Estimated duration in minutes.
|
|
final int estimatedMinutes;
|
|
|
|
/// Actual duration in minutes (null if not yet completed).
|
|
final int? actualMinutes;
|
|
|
|
/// Optional height for the bars.
|
|
final double barHeight;
|
|
|
|
const TimeVisualizer({
|
|
super.key,
|
|
required this.estimatedMinutes,
|
|
this.actualMinutes,
|
|
this.barHeight = 12,
|
|
});
|
|
|
|
/// Returns a color based on actual / estimated ratio.
|
|
/// <= 0.8 -> green (under)
|
|
/// 0.8-1.1 -> teal (on time)
|
|
/// 1.1-1.5 -> yellow (slightly over)
|
|
/// > 1.5 -> soft orange (over, but gentle)
|
|
Color _barColor(double ratio) {
|
|
if (ratio <= 0.8) return AppColors.completed;
|
|
if (ratio <= 1.1) return AppColors.primary;
|
|
if (ratio <= 1.5) return AppColors.energyMedium;
|
|
return AppColors.energyHigh; // soft orange, NOT red
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final actual = actualMinutes;
|
|
final hasActual = actual != null && actual > 0;
|
|
|
|
// If no actual yet, show just the estimate bar.
|
|
final ratio = hasActual ? actual / estimatedMinutes : 0.0;
|
|
final estimateWidth = 1.0; // always full width = estimate
|
|
final actualWidth = hasActual ? ratio.clamp(0.0, 2.0) / 2.0 : 0.0;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Labels
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Estimated: $estimatedMinutes min',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
if (hasActual)
|
|
Text(
|
|
'Actual: $actual min',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: _barColor(ratio),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
|
|
// ── Estimate bar (background / reference) ──────────────────
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(barHeight / 2),
|
|
child: Stack(
|
|
children: [
|
|
// Full estimate background
|
|
Container(
|
|
height: barHeight,
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary.withAlpha(30),
|
|
borderRadius: BorderRadius.circular(barHeight / 2),
|
|
),
|
|
),
|
|
|
|
// Actual fill
|
|
if (hasActual)
|
|
FractionallySizedBox(
|
|
widthFactor: actualWidth.clamp(0.0, estimateWidth),
|
|
child: Container(
|
|
height: barHeight,
|
|
decoration: BoxDecoration(
|
|
color: _barColor(ratio),
|
|
borderRadius: BorderRadius.circular(barHeight / 2),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// ── Gentle insight ─────────────────────────────────────────
|
|
if (hasActual) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_insightText(ratio),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: _barColor(ratio),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
String _insightText(double ratio) {
|
|
if (ratio <= 0.8) return 'Finished early — nice!';
|
|
if (ratio <= 1.1) return 'Right on target.';
|
|
if (ratio <= 1.5) return 'A little over — totally fine.';
|
|
return 'Took longer than expected. That happens!';
|
|
}
|
|
}
|