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>
392 lines
13 KiB
Dart
392 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:focusflow_shared/focusflow_shared.dart';
|
|
|
|
import '../../../../core/theme/app_colors.dart';
|
|
import '../../../../core/widgets/reward_popup.dart';
|
|
import '../bloc/next_task_cubit.dart';
|
|
|
|
/// THE core ADHD feature — "Just do the next thing."
|
|
///
|
|
/// - Single task displayed, nothing else.
|
|
/// - Large title text centered.
|
|
/// - Energy level indicator.
|
|
/// - Estimated time.
|
|
/// - Big "Done!" button (green, satisfying).
|
|
/// - "Skip" button (smaller, gray, no guilt).
|
|
/// - "I need a break" option.
|
|
/// - Timer showing elapsed time (gentle, not anxiety-inducing).
|
|
/// - On completion: reward popup appears.
|
|
/// - After reward: auto-loads next task or shows "All done!" celebration.
|
|
class FocusModeScreen extends StatefulWidget {
|
|
const FocusModeScreen({super.key});
|
|
|
|
@override
|
|
State<FocusModeScreen> createState() => _FocusModeScreenState();
|
|
}
|
|
|
|
class _FocusModeScreenState extends State<FocusModeScreen> {
|
|
late final NextTaskCubit _cubit;
|
|
final Stopwatch _stopwatch = Stopwatch();
|
|
Timer? _timer;
|
|
bool _showReward = false;
|
|
int _elapsedSeconds = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_cubit = NextTaskCubit()..loadNext();
|
|
_startTimer();
|
|
}
|
|
|
|
void _startTimer() {
|
|
_stopwatch.start();
|
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (mounted) {
|
|
setState(() => _elapsedSeconds = _stopwatch.elapsed.inSeconds);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _resetTimer() {
|
|
_stopwatch.reset();
|
|
setState(() => _elapsedSeconds = 0);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
_stopwatch.stop();
|
|
_cubit.close();
|
|
super.dispose();
|
|
}
|
|
|
|
String _formatElapsed() {
|
|
final minutes = _elapsedSeconds ~/ 60;
|
|
final seconds = _elapsedSeconds % 60;
|
|
return '${minutes}m ${seconds.toString().padLeft(2, '0')}s so far';
|
|
}
|
|
|
|
Color _energyColor(String level) {
|
|
switch (level) {
|
|
case 'low':
|
|
return AppColors.energyLow;
|
|
case 'high':
|
|
return AppColors.energyHigh;
|
|
default:
|
|
return AppColors.energyMedium;
|
|
}
|
|
}
|
|
|
|
String _energyEmoji(String level) {
|
|
switch (level) {
|
|
case 'low':
|
|
return 'Low energy';
|
|
case 'high':
|
|
return 'High energy';
|
|
default:
|
|
return 'Medium energy';
|
|
}
|
|
}
|
|
|
|
Future<void> _onComplete() async {
|
|
_stopwatch.stop();
|
|
setState(() => _showReward = true);
|
|
}
|
|
|
|
void _dismissReward() {
|
|
setState(() => _showReward = false);
|
|
_resetTimer();
|
|
_stopwatch.start();
|
|
_cubit.complete();
|
|
}
|
|
|
|
void _onSkip() {
|
|
_resetTimer();
|
|
_cubit.skip();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return BlocProvider.value(
|
|
value: _cubit,
|
|
child: Scaffold(
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
appBar: AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.close_rounded),
|
|
onPressed: () => context.go('/'),
|
|
tooltip: 'Exit focus mode',
|
|
),
|
|
title: const Text('Focus Mode'),
|
|
centerTitle: true,
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
BlocBuilder<NextTaskCubit, NextTaskState>(
|
|
builder: (context, state) {
|
|
if (state is NextTaskLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (state is NextTaskError) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.cloud_off_rounded,
|
|
size: 56, color: AppColors.skipped),
|
|
const SizedBox(height: 12),
|
|
Text(state.message,
|
|
style: theme.textTheme.bodyLarge,
|
|
textAlign: TextAlign.center),
|
|
const SizedBox(height: 16),
|
|
FilledButton(
|
|
onPressed: () => _cubit.loadNext(),
|
|
child: const Text('Try again'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state is NextTaskEmpty) {
|
|
return _AllDoneView(onGoBack: () => context.go('/'));
|
|
}
|
|
|
|
if (state is NextTaskLoaded) {
|
|
return _TaskFocusView(
|
|
task: state.task,
|
|
elapsed: _formatElapsed(),
|
|
energyColor: _energyColor(state.task.energyLevel),
|
|
energyLabel: _energyEmoji(state.task.energyLevel),
|
|
onComplete: _onComplete,
|
|
onSkip: _onSkip,
|
|
onBreak: () {
|
|
_stopwatch.stop();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text(
|
|
'Take your time. Tap anywhere when you\'re ready.'),
|
|
action: SnackBarAction(
|
|
label: 'Resume',
|
|
onPressed: () => _stopwatch.start(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
|
|
// ── Reward overlay ───────────────────────────────────
|
|
if (_showReward)
|
|
Container(
|
|
color: Colors.black.withAlpha(120),
|
|
child: RewardPopup(
|
|
reward: Reward(
|
|
id: 'local-${DateTime.now().millisecondsSinceEpoch}',
|
|
userId: '',
|
|
rewardType: 'points',
|
|
magnitude: 10,
|
|
title: 'Task complete!',
|
|
createdAt: DateTime.now(),
|
|
),
|
|
onDismiss: _dismissReward,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Single-task focus view ─────────────────────────────────────────
|
|
|
|
class _TaskFocusView extends StatelessWidget {
|
|
final Task task;
|
|
final String elapsed;
|
|
final Color energyColor;
|
|
final String energyLabel;
|
|
final VoidCallback onComplete;
|
|
final VoidCallback onSkip;
|
|
final VoidCallback onBreak;
|
|
|
|
const _TaskFocusView({
|
|
required this.task,
|
|
required this.elapsed,
|
|
required this.energyColor,
|
|
required this.energyLabel,
|
|
required this.onComplete,
|
|
required this.onSkip,
|
|
required this.onBreak,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
|
child: Column(
|
|
children: [
|
|
const Spacer(flex: 2),
|
|
|
|
// ── Energy indicator ─────────────────────────────────
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: energyColor.withAlpha(30),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.bolt_rounded, size: 18, color: energyColor),
|
|
const SizedBox(width: 4),
|
|
Text(energyLabel,
|
|
style: TextStyle(
|
|
color: energyColor, fontWeight: FontWeight.w600)),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// ── Task title ───────────────────────────────────────
|
|
Text(
|
|
task.title,
|
|
style: theme.textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// ── Estimated time ───────────────────────────────────
|
|
if (task.estimatedMinutes != null)
|
|
Text(
|
|
'~${task.estimatedMinutes} min estimated',
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// ── Elapsed time (gentle) ────────────────────────────
|
|
Text(
|
|
elapsed,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
|
|
const Spacer(flex: 3),
|
|
|
|
// ── Done button ──────────────────────────────────────
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 64,
|
|
child: FilledButton(
|
|
onPressed: onComplete,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.completed,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
textStyle: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
child: const Text('Done!', style: TextStyle(fontSize: 22, color: Colors.white)),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// ── Skip button (smaller, gray, no guilt) ────────────
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 52,
|
|
child: OutlinedButton(
|
|
onPressed: onSkip,
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: AppColors.skipped,
|
|
side: const BorderSide(color: AppColors.skipped),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
child: const Text('Skip for now'),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// ── Break button ─────────────────────────────────────
|
|
TextButton.icon(
|
|
onPressed: onBreak,
|
|
icon: const Icon(Icons.self_improvement_rounded, size: 20),
|
|
label: const Text('I need a break'),
|
|
),
|
|
|
|
const Spacer(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── All done celebration ───────────────────────────────────────────
|
|
|
|
class _AllDoneView extends StatelessWidget {
|
|
final VoidCallback onGoBack;
|
|
const _AllDoneView({required this.onGoBack});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.celebration_rounded, size: 80, color: AppColors.rewardGold),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'All done for today!',
|
|
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'You showed up and that matters. Enjoy your free time!',
|
|
style: theme.textTheme.bodyLarge,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 28),
|
|
FilledButton(
|
|
onPressed: onGoBack,
|
|
child: const Text('Back to dashboard'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|