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:
151
lib/features/auth/presentation/bloc/auth_bloc.dart
Normal file
151
lib/features/auth/presentation/bloc/auth_bloc.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:focusflow_shared/focusflow_shared.dart';
|
||||
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/network/interceptors/auth_interceptor.dart';
|
||||
import '../../../../main.dart';
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────
|
||||
|
||||
sealed class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AuthLoginRequested extends AuthEvent {
|
||||
final String email;
|
||||
final String password;
|
||||
const AuthLoginRequested({required this.email, required this.password});
|
||||
@override
|
||||
List<Object?> get props => [email, password];
|
||||
}
|
||||
|
||||
class AuthSignupRequested extends AuthEvent {
|
||||
final String displayName;
|
||||
final String email;
|
||||
final String password;
|
||||
const AuthSignupRequested({
|
||||
required this.displayName,
|
||||
required this.email,
|
||||
required this.password,
|
||||
});
|
||||
@override
|
||||
List<Object?> get props => [displayName, email, password];
|
||||
}
|
||||
|
||||
class AuthLogoutRequested extends AuthEvent {
|
||||
const AuthLogoutRequested();
|
||||
}
|
||||
|
||||
class AuthCheckRequested extends AuthEvent {}
|
||||
|
||||
// ── States ─────────────────────────────────────────────────────────
|
||||
|
||||
sealed class AuthState extends Equatable {
|
||||
const AuthState();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AuthInitial extends AuthState {}
|
||||
|
||||
class AuthLoading extends AuthState {}
|
||||
|
||||
class AuthAuthenticated extends AuthState {
|
||||
final User user;
|
||||
const AuthAuthenticated(this.user);
|
||||
@override
|
||||
List<Object?> get props => [user];
|
||||
}
|
||||
|
||||
class AuthUnauthenticated extends AuthState {}
|
||||
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
const AuthError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// ── BLoC ───────────────────────────────────────────────────────────
|
||||
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final ApiClient _api = getIt<ApiClient>();
|
||||
|
||||
AuthBloc() : super(AuthInitial()) {
|
||||
on<AuthCheckRequested>(_onCheck);
|
||||
on<AuthLoginRequested>(_onLogin);
|
||||
on<AuthSignupRequested>(_onSignup);
|
||||
on<AuthLogoutRequested>(_onLogout);
|
||||
}
|
||||
|
||||
Future<void> _onCheck(AuthCheckRequested event, Emitter<AuthState> emit) async {
|
||||
final hasToken = await AuthInterceptor.hasToken();
|
||||
if (hasToken) {
|
||||
// In a production app we would validate the token and fetch the user profile.
|
||||
// For now we emit a placeholder user.
|
||||
emit(AuthAuthenticated(
|
||||
User(
|
||||
id: 'local',
|
||||
email: '',
|
||||
displayName: 'Friend',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
));
|
||||
} else {
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogin(AuthLoginRequested event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final response = await _api.login(event.email, event.password);
|
||||
if (response.status == 'success' && response.data != null) {
|
||||
final data = response.data!;
|
||||
await AuthInterceptor.saveTokens(
|
||||
accessToken: data['accessToken'] as String,
|
||||
refreshToken: data['refreshToken'] as String,
|
||||
);
|
||||
final user = User.fromJson(data['user'] as Map<String, dynamic>);
|
||||
emit(AuthAuthenticated(user));
|
||||
} else {
|
||||
emit(AuthError(response.error?.message ?? 'Login failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(AuthError('Something went wrong. Please try again.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSignup(AuthSignupRequested event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final response = await _api.register(event.displayName, event.email, event.password);
|
||||
if (response.status == 'success' && response.data != null) {
|
||||
final data = response.data!;
|
||||
await AuthInterceptor.saveTokens(
|
||||
accessToken: data['accessToken'] as String,
|
||||
refreshToken: data['refreshToken'] as String,
|
||||
);
|
||||
final user = User.fromJson(data['user'] as Map<String, dynamic>);
|
||||
emit(AuthAuthenticated(user));
|
||||
} else {
|
||||
emit(AuthError(response.error?.message ?? 'Sign up failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(AuthError('Something went wrong. Please try again.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogout(AuthLogoutRequested event, Emitter<AuthState> emit) async {
|
||||
try {
|
||||
await _api.logout();
|
||||
} catch (_) {
|
||||
// Best-effort server logout — clear local tokens regardless.
|
||||
}
|
||||
await AuthInterceptor.clearTokens();
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
}
|
||||
153
lib/features/auth/presentation/screens/login_screen.dart
Normal file
153
lib/features/auth/presentation/screens/login_screen.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
|
||||
/// Clean, ADHD-friendly login screen.
|
||||
///
|
||||
/// Minimal fields, large touch targets, clear labels, calming tone.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
context.read<AuthBloc>().add(AuthLoginRequested(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthAuthenticated) {
|
||||
context.go('/');
|
||||
} else if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Logo / Title ─────────────────────────────────
|
||||
Icon(Icons.self_improvement_rounded,
|
||||
size: 64, color: AppColors.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text('FocusFlow', style: theme.textTheme.headlineLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Welcome back. Let\'s get things done — gently.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// ── Email ────────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Email is required';
|
||||
if (!v.contains('@')) return 'Enter a valid email';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Password ─────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Password is required';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// ── Sign in button ───────────────────────────────
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final loading = state is AuthLoading;
|
||||
return FilledButton(
|
||||
onPressed: loading ? null : _submit,
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Sign In'),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Create account link ──────────────────────────
|
||||
TextButton(
|
||||
onPressed: () => context.go('/signup'),
|
||||
child: const Text('Don\'t have an account? Create one'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
169
lib/features/auth/presentation/screens/signup_screen.dart
Normal file
169
lib/features/auth/presentation/screens/signup_screen.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
|
||||
/// Sign-up screen — minimal fields to reduce friction.
|
||||
class SignupScreen extends StatefulWidget {
|
||||
const SignupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends State<SignupScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
context.read<AuthBloc>().add(AuthSignupRequested(
|
||||
displayName: _nameController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthAuthenticated) {
|
||||
context.go('/onboarding');
|
||||
} else if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.emoji_nature_rounded,
|
||||
size: 64, color: AppColors.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text('Join FocusFlow', style: theme.textTheme.headlineLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Built for brains like yours. No judgement, just support.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// ── Display name ─────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'What should we call you?',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'A name helps us personalise things';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Email ────────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Email is required';
|
||||
if (!v.contains('@')) return 'Enter a valid email';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Password ─────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.length < 8) {
|
||||
return 'At least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// ── Create account button ────────────────────────
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final loading = state is AuthLoading;
|
||||
return FilledButton(
|
||||
onPressed: loading ? null : _submit,
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Create Account'),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
child: const Text('Already have an account? Sign in'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
/// Placeholder screen for the body doubling / virtual coworking rooms feature.
|
||||
class RoomsScreen extends StatelessWidget {
|
||||
const RoomsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
title: const Text('Body Doubling'),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.groups_rounded, size: 80, color: AppColors.tertiary.withAlpha(180)),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Body Doubling',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Coming Soon',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: AppColors.tertiary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Body doubling is a technique where having another person nearby '
|
||||
'helps you stay focused. Soon you\'ll be able to join virtual '
|
||||
'coworking rooms, work alongside others in real-time, and pick '
|
||||
'ambient sounds like a caf\u00e9 or rain.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('We\'ll notify you when body doubling rooms launch!'),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.notifications_active_outlined),
|
||||
label: const Text('Notify me'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/features/rewards/presentation/cubit/reward_cubit.dart
Normal file
48
lib/features/rewards/presentation/cubit/reward_cubit.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:focusflow_shared/focusflow_shared.dart';
|
||||
|
||||
// ── States ─────────────────────────────────────────────────────────
|
||||
|
||||
sealed class RewardState extends Equatable {
|
||||
const RewardState();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class RewardIdle extends RewardState {}
|
||||
|
||||
class RewardShowing extends RewardState {
|
||||
final Reward reward;
|
||||
const RewardShowing(this.reward);
|
||||
@override
|
||||
List<Object?> get props => [reward];
|
||||
}
|
||||
|
||||
class RewardDismissed extends RewardState {}
|
||||
|
||||
// ── Cubit ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Manages the reward overlay display lifecycle.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. After a task is completed, call [showReward].
|
||||
/// 2. The UI renders [RewardPopup] when state is [RewardShowing].
|
||||
/// 3. User taps dismiss -> call [dismiss].
|
||||
class RewardCubit extends Cubit<RewardState> {
|
||||
RewardCubit() : super(RewardIdle());
|
||||
|
||||
/// Show a reward to the user.
|
||||
void showReward(Reward reward) {
|
||||
emit(RewardShowing(reward));
|
||||
}
|
||||
|
||||
/// Dismiss the current reward and return to idle.
|
||||
void dismiss() {
|
||||
emit(RewardDismissed());
|
||||
// Brief delay then return to idle so the cubit is ready for the next reward.
|
||||
Future<void>.delayed(const Duration(milliseconds: 300), () {
|
||||
if (!isClosed) emit(RewardIdle());
|
||||
});
|
||||
}
|
||||
}
|
||||
196
lib/features/settings/presentation/screens/settings_screen.dart
Normal file
196
lib/features/settings/presentation/screens/settings_screen.dart
Normal file
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../features/auth/presentation/bloc/auth_bloc.dart';
|
||||
|
||||
/// Settings screen.
|
||||
///
|
||||
/// - Notification preferences.
|
||||
/// - Daily task load slider (1-10).
|
||||
/// - Focus session length.
|
||||
/// - Reward style (playful / minimal / data).
|
||||
/// - Forgiveness toggle.
|
||||
/// - Theme (light / dark / system).
|
||||
/// - Account management.
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// ── Local preference state ───────────────────────────────────────
|
||||
bool _notificationsEnabled = true;
|
||||
double _taskLoad = 5;
|
||||
double _focusMinutes = 25;
|
||||
String _rewardStyle = 'playful';
|
||||
bool _forgivenessEnabled = true;
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
// ── Notifications ────────────────────────────────────────
|
||||
_SectionTitle('Notifications'),
|
||||
SwitchListTile(
|
||||
title: const Text('Enable notifications'),
|
||||
subtitle: const Text('Gentle reminders, never nagging'),
|
||||
value: _notificationsEnabled,
|
||||
onChanged: (v) => setState(() => _notificationsEnabled = v),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// ── Daily task load ──────────────────────────────────────
|
||||
_SectionTitle('Daily Task Load'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _taskLoad,
|
||||
min: 1,
|
||||
max: 10,
|
||||
divisions: 9,
|
||||
label: _taskLoad.round().toString(),
|
||||
onChanged: (v) => setState(() => _taskLoad = v),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_taskLoad.round()} tasks',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// ── Focus session length ─────────────────────────────────
|
||||
_SectionTitle('Focus Session Length'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _focusMinutes,
|
||||
min: 5,
|
||||
max: 60,
|
||||
divisions: 11,
|
||||
label: '${_focusMinutes.round()} min',
|
||||
onChanged: (v) => setState(() => _focusMinutes = v),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_focusMinutes.round()} min',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// ── Reward style ─────────────────────────────────────────
|
||||
_SectionTitle('Reward Style'),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Playful (animations & messages)'),
|
||||
value: 'playful',
|
||||
groupValue: _rewardStyle,
|
||||
onChanged: (v) => setState(() => _rewardStyle = v!),
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Minimal (subtle feedback)'),
|
||||
value: 'minimal',
|
||||
groupValue: _rewardStyle,
|
||||
onChanged: (v) => setState(() => _rewardStyle = v!),
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Data-driven (stats & charts)'),
|
||||
value: 'data',
|
||||
groupValue: _rewardStyle,
|
||||
onChanged: (v) => setState(() => _rewardStyle = v!),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// ── Forgiveness toggle ───────────────────────────────────
|
||||
_SectionTitle('Forgiveness'),
|
||||
SwitchListTile(
|
||||
title: const Text('Enable grace days'),
|
||||
subtitle: const Text('Missed a day? Grace days protect your streaks.'),
|
||||
value: _forgivenessEnabled,
|
||||
onChanged: (v) => setState(() => _forgivenessEnabled = v),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// ── Theme ────────────────────────────────────────────────
|
||||
_SectionTitle('Theme'),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('System default'),
|
||||
value: ThemeMode.system,
|
||||
groupValue: _themeMode,
|
||||
onChanged: (v) => setState(() => _themeMode = v!),
|
||||
),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('Light'),
|
||||
value: ThemeMode.light,
|
||||
groupValue: _themeMode,
|
||||
onChanged: (v) => setState(() => _themeMode = v!),
|
||||
),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('Dark'),
|
||||
value: ThemeMode.dark,
|
||||
groupValue: _themeMode,
|
||||
onChanged: (v) => setState(() => _themeMode = v!),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// ── Account ──────────────────────────────────────────────
|
||||
_SectionTitle('Account'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout_rounded, color: AppColors.error),
|
||||
title: const Text('Sign out'),
|
||||
onTap: () {
|
||||
context.read<AuthBloc>().add(const AuthLogoutRequested());
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionTitle(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/features/streaks/presentation/bloc/streak_bloc.dart
Normal file
120
lib/features/streaks/presentation/bloc/streak_bloc.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:focusflow_shared/focusflow_shared.dart';
|
||||
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../main.dart';
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────
|
||||
|
||||
sealed class StreakEvent extends Equatable {
|
||||
const StreakEvent();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StreaksLoaded extends StreakEvent {
|
||||
const StreaksLoaded();
|
||||
}
|
||||
|
||||
class StreakCompleted extends StreakEvent {
|
||||
final String id;
|
||||
const StreakCompleted(this.id);
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
class StreakForgiven extends StreakEvent {
|
||||
final String id;
|
||||
const StreakForgiven(this.id);
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
class StreakFrozen extends StreakEvent {
|
||||
final String id;
|
||||
final DateTime until;
|
||||
const StreakFrozen(this.id, this.until);
|
||||
@override
|
||||
List<Object?> get props => [id, until];
|
||||
}
|
||||
|
||||
// ── States ─────────────────────────────────────────────────────────
|
||||
|
||||
sealed class StreakState extends Equatable {
|
||||
const StreakState();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StreakInitial extends StreakState {}
|
||||
|
||||
class StreakLoading extends StreakState {}
|
||||
|
||||
class StreakLoaded extends StreakState {
|
||||
final List<Streak> streaks;
|
||||
const StreakLoaded(this.streaks);
|
||||
@override
|
||||
List<Object?> get props => [streaks];
|
||||
}
|
||||
|
||||
class StreakError extends StreakState {
|
||||
final String message;
|
||||
const StreakError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// ── BLoC ───────────────────────────────────────────────────────────
|
||||
|
||||
class StreakBloc extends Bloc<StreakEvent, StreakState> {
|
||||
final ApiClient _api = getIt<ApiClient>();
|
||||
|
||||
StreakBloc() : super(StreakInitial()) {
|
||||
on<StreaksLoaded>(_onLoaded);
|
||||
on<StreakCompleted>(_onCompleted);
|
||||
on<StreakForgiven>(_onForgiven);
|
||||
on<StreakFrozen>(_onFrozen);
|
||||
}
|
||||
|
||||
Future<void> _onLoaded(StreaksLoaded event, Emitter<StreakState> emit) async {
|
||||
emit(StreakLoading());
|
||||
try {
|
||||
final response = await _api.fetchStreaks();
|
||||
final data = response.data;
|
||||
if (data != null && data['status'] == 'success') {
|
||||
final items = (data['data'] as List<dynamic>)
|
||||
.map((e) => Streak.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
emit(StreakLoaded(items));
|
||||
} else {
|
||||
emit(const StreakError('Could not load streaks.'));
|
||||
}
|
||||
} catch (_) {
|
||||
emit(const StreakError('Something went wrong loading streaks.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCompleted(StreakCompleted event, Emitter<StreakState> emit) async {
|
||||
try {
|
||||
await _api.completeStreak(event.id);
|
||||
add(const StreaksLoaded());
|
||||
} catch (_) {
|
||||
emit(const StreakError('Could not complete streak entry.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onForgiven(StreakForgiven event, Emitter<StreakState> emit) async {
|
||||
try {
|
||||
await _api.forgiveStreak(event.id);
|
||||
add(const StreaksLoaded());
|
||||
} catch (_) {
|
||||
emit(const StreakError('Could not forgive streak.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onFrozen(StreakFrozen event, Emitter<StreakState> emit) async {
|
||||
// TODO: API call to freeze streak until `event.until`.
|
||||
add(const StreaksLoaded());
|
||||
}
|
||||
}
|
||||
229
lib/features/streaks/presentation/screens/streaks_screen.dart
Normal file
229
lib/features/streaks/presentation/screens/streaks_screen.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:focusflow_shared/focusflow_shared.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/streak_ring.dart';
|
||||
import '../bloc/streak_bloc.dart';
|
||||
|
||||
/// Streak overview screen.
|
||||
///
|
||||
/// - Active streaks as cards with [StreakRing] widget.
|
||||
/// - "Frozen" indicator for paused streaks.
|
||||
/// - Tap to see history.
|
||||
/// - "New Streak" FAB.
|
||||
/// - Grace day info shown positively.
|
||||
class StreaksScreen extends StatefulWidget {
|
||||
const StreaksScreen({super.key});
|
||||
|
||||
@override
|
||||
State<StreaksScreen> createState() => _StreaksScreenState();
|
||||
}
|
||||
|
||||
class _StreaksScreenState extends State<StreaksScreen> {
|
||||
late final StreakBloc _bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = StreakBloc()..add(const StreaksLoaded());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
title: const Text('Your Streaks'),
|
||||
),
|
||||
body: BlocBuilder<StreakBloc, StreakState>(
|
||||
builder: (context, state) {
|
||||
if (state is StreakLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is StreakError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off_rounded, size: 48, color: AppColors.skipped),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: theme.textTheme.bodyLarge, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => _bloc.add(const StreaksLoaded()),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is StreakLoaded) {
|
||||
if (state.streaks.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.local_fire_department_rounded,
|
||||
size: 56, color: AppColors.tertiary),
|
||||
const SizedBox(height: 12),
|
||||
Text('No streaks yet',
|
||||
style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Start a streak to build consistency — with grace days built in.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 100),
|
||||
itemCount: state.streaks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final streak = state.streaks[index];
|
||||
return _StreakCard(
|
||||
streak: streak,
|
||||
onComplete: () => _bloc.add(StreakCompleted(streak.id)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
// TODO: navigate to create streak screen.
|
||||
},
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('New Streak'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StreakCard extends StatelessWidget {
|
||||
final Streak streak;
|
||||
final VoidCallback onComplete;
|
||||
|
||||
const _StreakCard({required this.streak, required this.onComplete});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isFrozen = streak.frozenUntil != null &&
|
||||
streak.frozenUntil!.isAfter(DateTime.now());
|
||||
final graceDaysRemaining = streak.graceDays - streak.graceUsed;
|
||||
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
// TODO: navigate to streak history.
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreakRing(
|
||||
currentCount: streak.currentCount,
|
||||
graceDaysRemaining: graceDaysRemaining,
|
||||
totalGraceDays: streak.graceDays,
|
||||
isFrozen: isFrozen,
|
||||
size: 72,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
streak.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isFrozen)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryLight.withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.ac_unit_rounded, size: 14, color: AppColors.primaryLight),
|
||||
SizedBox(width: 4),
|
||||
Text('Frozen',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.primaryLight)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Current: ${streak.currentCount} days | Best: ${streak.longestCount}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (graceDaysRemaining > 0 && !isFrozen)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'$graceDaysRemaining grace days remaining',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isFrozen)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check_circle_outline_rounded,
|
||||
color: AppColors.completed),
|
||||
onPressed: onComplete,
|
||||
tooltip: 'Complete today',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/features/tasks/presentation/bloc/next_task_cubit.dart
Normal file
84
lib/features/tasks/presentation/bloc/next_task_cubit.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:focusflow_shared/focusflow_shared.dart';
|
||||
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../main.dart';
|
||||
|
||||
// ── States ─────────────────────────────────────────────────────────
|
||||
|
||||
sealed class NextTaskState extends Equatable {
|
||||
const NextTaskState();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class NextTaskLoading extends NextTaskState {}
|
||||
|
||||
class NextTaskLoaded extends NextTaskState {
|
||||
final Task task;
|
||||
const NextTaskLoaded(this.task);
|
||||
@override
|
||||
List<Object?> get props => [task];
|
||||
}
|
||||
|
||||
class NextTaskEmpty extends NextTaskState {}
|
||||
|
||||
class NextTaskError extends NextTaskState {
|
||||
final String message;
|
||||
const NextTaskError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// ── Cubit ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Simple cubit for focus mode: "just do the next thing."
|
||||
///
|
||||
/// Loads the single highest-priority task from the API.
|
||||
class NextTaskCubit extends Cubit<NextTaskState> {
|
||||
final ApiClient _api = getIt<ApiClient>();
|
||||
|
||||
NextTaskCubit() : super(NextTaskLoading());
|
||||
|
||||
/// Fetch the next recommended task.
|
||||
Future<void> loadNext() async {
|
||||
emit(NextTaskLoading());
|
||||
try {
|
||||
final response = await _api.fetchNextTask();
|
||||
final data = response.data;
|
||||
if (data != null && data['status'] == 'success' && data['data'] != null) {
|
||||
final task = Task.fromJson(data['data'] as Map<String, dynamic>);
|
||||
emit(NextTaskLoaded(task));
|
||||
} else {
|
||||
emit(NextTaskEmpty());
|
||||
}
|
||||
} catch (_) {
|
||||
emit(const NextTaskError('Could not load your next task.'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the current task as done, then load the next one.
|
||||
Future<void> complete() async {
|
||||
final current = state;
|
||||
if (current is! NextTaskLoaded) return;
|
||||
try {
|
||||
await _api.completeTask(current.task.id);
|
||||
} catch (_) {
|
||||
// Best-effort — still load next.
|
||||
}
|
||||
await loadNext();
|
||||
}
|
||||
|
||||
/// Skip the current task, then load the next one.
|
||||
Future<void> skip() async {
|
||||
final current = state;
|
||||
if (current is! NextTaskLoaded) return;
|
||||
try {
|
||||
await _api.skipTask(current.task.id);
|
||||
} catch (_) {
|
||||
// Best-effort.
|
||||
}
|
||||
await loadNext();
|
||||
}
|
||||
}
|
||||
141
lib/features/tasks/presentation/bloc/task_list_bloc.dart
Normal file
141
lib/features/tasks/presentation/bloc/task_list_bloc.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:focusflow_shared/focusflow_shared.dart';
|
||||
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../main.dart';
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────
|
||||
|
||||
sealed class TaskListEvent extends Equatable {
|
||||
const TaskListEvent();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class TasksLoaded extends TaskListEvent {
|
||||
const TasksLoaded();
|
||||
}
|
||||
|
||||
class TaskCompleted extends TaskListEvent {
|
||||
final String id;
|
||||
const TaskCompleted(this.id);
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
class TaskSkipped extends TaskListEvent {
|
||||
final String id;
|
||||
const TaskSkipped(this.id);
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
class TaskCreated extends TaskListEvent {
|
||||
final Task task;
|
||||
const TaskCreated(this.task);
|
||||
@override
|
||||
List<Object?> get props => [task];
|
||||
}
|
||||
|
||||
class TaskDeleted extends TaskListEvent {
|
||||
final String id;
|
||||
const TaskDeleted(this.id);
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
// ── States ─────────────────────────────────────────────────────────
|
||||
|
||||
sealed class TaskListState extends Equatable {
|
||||
const TaskListState();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class TaskListInitial extends TaskListState {}
|
||||
|
||||
class TaskListLoading extends TaskListState {}
|
||||
|
||||
class TaskListLoaded extends TaskListState {
|
||||
final List<Task> tasks;
|
||||
const TaskListLoaded(this.tasks);
|
||||
@override
|
||||
List<Object?> get props => [tasks];
|
||||
}
|
||||
|
||||
class TaskListError extends TaskListState {
|
||||
final String message;
|
||||
const TaskListError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// ── BLoC ───────────────────────────────────────────────────────────
|
||||
|
||||
class TaskListBloc extends Bloc<TaskListEvent, TaskListState> {
|
||||
final ApiClient _api = getIt<ApiClient>();
|
||||
|
||||
TaskListBloc() : super(TaskListInitial()) {
|
||||
on<TasksLoaded>(_onLoaded);
|
||||
on<TaskCompleted>(_onCompleted);
|
||||
on<TaskSkipped>(_onSkipped);
|
||||
on<TaskCreated>(_onCreated);
|
||||
on<TaskDeleted>(_onDeleted);
|
||||
}
|
||||
|
||||
Future<void> _onLoaded(TasksLoaded event, Emitter<TaskListState> emit) async {
|
||||
emit(TaskListLoading());
|
||||
try {
|
||||
final response = await _api.fetchTasks();
|
||||
final data = response.data;
|
||||
if (data != null && data['status'] == 'success') {
|
||||
final items = (data['data'] as List<dynamic>)
|
||||
.map((e) => Task.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
emit(TaskListLoaded(items));
|
||||
} else {
|
||||
emit(const TaskListError('Could not load tasks.'));
|
||||
}
|
||||
} catch (_) {
|
||||
emit(const TaskListError('Something went wrong loading tasks.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCompleted(TaskCompleted event, Emitter<TaskListState> emit) async {
|
||||
try {
|
||||
await _api.completeTask(event.id);
|
||||
// Re-fetch after completion so the list is up to date.
|
||||
add(const TasksLoaded());
|
||||
} catch (_) {
|
||||
emit(const TaskListError('Could not complete task.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSkipped(TaskSkipped event, Emitter<TaskListState> emit) async {
|
||||
try {
|
||||
await _api.skipTask(event.id);
|
||||
add(const TasksLoaded());
|
||||
} catch (_) {
|
||||
emit(const TaskListError('Could not skip task.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreated(TaskCreated event, Emitter<TaskListState> emit) async {
|
||||
try {
|
||||
await _api.createTask(event.task.toJson());
|
||||
add(const TasksLoaded());
|
||||
} catch (_) {
|
||||
emit(const TaskListError('Could not create task.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeleted(TaskDeleted event, Emitter<TaskListState> emit) async {
|
||||
try {
|
||||
await _api.deleteTask(event.id);
|
||||
add(const TasksLoaded());
|
||||
} catch (_) {
|
||||
emit(const TaskListError('Could not delete task.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
120
lib/features/tasks/presentation/screens/create_task_screen.dart
Normal file
120
lib/features/tasks/presentation/screens/create_task_screen.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../widgets/energy_selector.dart';
|
||||
|
||||
/// Quick task creation screen — minimal friction.
|
||||
///
|
||||
/// Fields:
|
||||
/// - Title (required).
|
||||
/// - Energy level (default medium).
|
||||
/// - Estimated minutes.
|
||||
/// - Tags.
|
||||
class CreateTaskScreen extends StatefulWidget {
|
||||
const CreateTaskScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CreateTaskScreen> createState() => _CreateTaskScreenState();
|
||||
}
|
||||
|
||||
class _CreateTaskScreenState extends State<CreateTaskScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleController = TextEditingController();
|
||||
final _minutesController = TextEditingController(text: '15');
|
||||
final _tagsController = TextEditingController();
|
||||
String _energyLevel = 'medium';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_minutesController.dispose();
|
||||
_tagsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _create() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
// TODO: dispatch TaskCreated via BLoC.
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Task created!')),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('New Task')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'What do you need to do?',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Title ──────────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Task title',
|
||||
hintText: 'e.g. Reply to email',
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'What is it?' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Energy level ───────────────────────────────────
|
||||
Text('How much energy will this take?',
|
||||
style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
EnergySelector(
|
||||
value: _energyLevel,
|
||||
onChanged: (v) => setState(() => _energyLevel = v),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Estimated minutes ──────────────────────────────
|
||||
TextFormField(
|
||||
controller: _minutesController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Estimated minutes',
|
||||
suffixText: 'min',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Tags ───────────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Tags (optional, comma separated)',
|
||||
prefixIcon: Icon(Icons.label_outline),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── Create button ──────────────────────────────────
|
||||
FilledButton(
|
||||
onPressed: _create,
|
||||
child: const Text('Create Task'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
391
lib/features/tasks/presentation/screens/focus_mode_screen.dart
Normal file
391
lib/features/tasks/presentation/screens/focus_mode_screen.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
362
lib/features/tasks/presentation/screens/onboarding_screen.dart
Normal file
362
lib/features/tasks/presentation/screens/onboarding_screen.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../widgets/energy_selector.dart';
|
||||
|
||||
/// ADHD-specific onboarding flow (4 steps).
|
||||
///
|
||||
/// 1. Welcome — "Built for brains like yours."
|
||||
/// 2. How it works — focus mode, rewards, forgiveness.
|
||||
/// 3. Preferences — daily task load, energy preference, focus session length.
|
||||
/// 4. Ready — "Let's do this! No pressure."
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
|
||||
// ── Preference values ────────────────────────────────────────────
|
||||
double _taskLoad = 5;
|
||||
String _energyPreference = 'medium';
|
||||
double _focusMinutes = 25;
|
||||
|
||||
void _next() {
|
||||
if (_currentPage < 3) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} else {
|
||||
// Onboarding complete — go home.
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Page indicator ────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 28),
|
||||
child: Row(
|
||||
children: List.generate(4, (i) {
|
||||
final active = i <= _currentPage;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primary : AppColors.skipped.withAlpha(80),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Pages ────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: (i) => setState(() => _currentPage = i),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
_WelcomePage(theme: theme),
|
||||
_HowItWorksPage(theme: theme),
|
||||
_PreferencesPage(
|
||||
theme: theme,
|
||||
taskLoad: _taskLoad,
|
||||
onTaskLoadChanged: (v) => setState(() => _taskLoad = v),
|
||||
energyPreference: _energyPreference,
|
||||
onEnergyChanged: (v) => setState(() => _energyPreference = v),
|
||||
focusMinutes: _focusMinutes,
|
||||
onFocusChanged: (v) => setState(() => _focusMinutes = v),
|
||||
),
|
||||
_ReadyPage(theme: theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Bottom button ────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 8, 28, 24),
|
||||
child: FilledButton(
|
||||
onPressed: _next,
|
||||
child: Text(_currentPage == 3 ? 'Let\'s go!' : 'Continue'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 1: Welcome ────────────────────────────────────────────────
|
||||
|
||||
class _WelcomePage extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
const _WelcomePage({required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.self_improvement_rounded, size: 80, color: AppColors.primary),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Built for brains like yours',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'FocusFlow is designed with ADHD in mind. '
|
||||
'No overwhelming lists, no guilt — just gentle support '
|
||||
'to help you get things done, one task at a time.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: How it works ───────────────────────────────────────────
|
||||
|
||||
class _HowItWorksPage extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
const _HowItWorksPage({required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'How it works',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_FeatureRow(
|
||||
icon: Icons.center_focus_strong_rounded,
|
||||
color: AppColors.primary,
|
||||
title: 'Focus Mode',
|
||||
subtitle: 'One task at a time. No distractions.',
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_FeatureRow(
|
||||
icon: Icons.celebration_rounded,
|
||||
color: AppColors.secondary,
|
||||
title: 'Rewards',
|
||||
subtitle: 'Earn points & celebrations for completing tasks.',
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_FeatureRow(
|
||||
icon: Icons.favorite_rounded,
|
||||
color: AppColors.tertiary,
|
||||
title: 'Forgiveness',
|
||||
subtitle: 'Missed a day? Grace days have your back. No guilt.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
|
||||
const _FeatureRow({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 28),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: Preferences ────────────────────────────────────────────
|
||||
|
||||
class _PreferencesPage extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
final double taskLoad;
|
||||
final ValueChanged<double> onTaskLoadChanged;
|
||||
final String energyPreference;
|
||||
final ValueChanged<String> onEnergyChanged;
|
||||
final double focusMinutes;
|
||||
final ValueChanged<double> onFocusChanged;
|
||||
|
||||
const _PreferencesPage({
|
||||
required this.theme,
|
||||
required this.taskLoad,
|
||||
required this.onTaskLoadChanged,
|
||||
required this.energyPreference,
|
||||
required this.onEnergyChanged,
|
||||
required this.focusMinutes,
|
||||
required this.onFocusChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Set your preferences',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'You can always change these later.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Daily task load
|
||||
Text('How many tasks per day feel right?', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: taskLoad,
|
||||
min: 1,
|
||||
max: 10,
|
||||
divisions: 9,
|
||||
label: taskLoad.round().toString(),
|
||||
onChanged: onTaskLoadChanged,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${taskLoad.round()}',
|
||||
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Energy preference
|
||||
Text('What\'s your usual energy level?', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
EnergySelector(value: energyPreference, onChanged: onEnergyChanged),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Focus session length
|
||||
Text('Focus session length', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: focusMinutes,
|
||||
min: 5,
|
||||
max: 60,
|
||||
divisions: 11,
|
||||
label: '${focusMinutes.round()} min',
|
||||
onChanged: onFocusChanged,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${focusMinutes.round()} min',
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: Ready ──────────────────────────────────────────────────
|
||||
|
||||
class _ReadyPage extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
const _ReadyPage({required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.rocket_launch_rounded, size: 80, color: AppColors.primary),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'You\'re all set!',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Let\'s do this. No pressure — start with one task '
|
||||
'and see how it feels. You\'ve got this.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/gentle_nudge_card.dart';
|
||||
import '../../../../core/widgets/streak_ring.dart';
|
||||
import '../../../../core/widgets/task_card.dart';
|
||||
import '../../../../features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import '../bloc/task_list_bloc.dart';
|
||||
|
||||
/// Home screen — the task dashboard.
|
||||
///
|
||||
/// Layout:
|
||||
/// - AppBar greeting.
|
||||
/// - Focus Mode prominent card at the top.
|
||||
/// - Today's tasks list (limited to preferredTaskLoad).
|
||||
/// - Streak summary cards (horizontal scroll).
|
||||
/// - Bottom nav: Tasks, Streaks, Time, Settings.
|
||||
class TaskDashboardScreen extends StatefulWidget {
|
||||
const TaskDashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TaskDashboardScreen> createState() => _TaskDashboardScreenState();
|
||||
}
|
||||
|
||||
class _TaskDashboardScreenState extends State<TaskDashboardScreen> {
|
||||
late final TaskListBloc _taskListBloc;
|
||||
int _currentNavIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_taskListBloc = TaskListBloc()..add(const TasksLoaded());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_taskListBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _greeting(AuthState authState) {
|
||||
final name = authState is AuthAuthenticated ? authState.user.displayName : 'Friend';
|
||||
return 'Hey $name! What shall we tackle?';
|
||||
}
|
||||
|
||||
void _onNavTap(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
break; // already here
|
||||
case 1:
|
||||
context.go('/streaks');
|
||||
case 2:
|
||||
context.go('/time');
|
||||
case 3:
|
||||
context.go('/settings');
|
||||
}
|
||||
setState(() => _currentNavIndex = index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return BlocProvider.value(
|
||||
value: _taskListBloc,
|
||||
child: Scaffold(
|
||||
// ── AppBar ───────────────────────────────────────────────
|
||||
appBar: AppBar(
|
||||
title: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => Text(
|
||||
_greeting(state),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_none_rounded),
|
||||
onPressed: () {},
|
||||
tooltip: 'Notifications',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ── Body ─────────────────────────────────────────────────
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => _taskListBloc.add(const TasksLoaded()),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
children: [
|
||||
// Focus Mode card
|
||||
_FocusModeCard(onTap: () => context.go('/focus')),
|
||||
|
||||
// Gentle nudge (shown conditionally)
|
||||
GentleNudgeCard(
|
||||
previousStreak: 7,
|
||||
onStartSmall: () => context.go('/focus'),
|
||||
onDismiss: () {},
|
||||
),
|
||||
|
||||
// ── Streak rings (horizontal scroll) ───────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text('Your Streaks', style: theme.textTheme.titleMedium),
|
||||
),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: StreakRing(
|
||||
currentCount: 12,
|
||||
graceDaysRemaining: 2,
|
||||
totalGraceDays: 2,
|
||||
label: 'Daily tasks',
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: StreakRing(
|
||||
currentCount: 5,
|
||||
graceDaysRemaining: 1,
|
||||
totalGraceDays: 2,
|
||||
label: 'Exercise',
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: StreakRing(
|
||||
currentCount: 0,
|
||||
isFrozen: true,
|
||||
label: 'Frozen',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Today's tasks ──────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text('Today\'s Tasks', style: theme.textTheme.titleMedium),
|
||||
),
|
||||
|
||||
BlocBuilder<TaskListBloc, TaskListState>(
|
||||
builder: (context, state) {
|
||||
if (state is TaskListLoading) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is TaskListError) {
|
||||
return _EmptyState(
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: 'Could not load tasks',
|
||||
subtitle: state.message,
|
||||
);
|
||||
}
|
||||
|
||||
if (state is TaskListLoaded) {
|
||||
final tasks = state.tasks;
|
||||
if (tasks.isEmpty) {
|
||||
return const _EmptyState(
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
title: 'All clear!',
|
||||
subtitle: 'No tasks for today. Enjoy the calm.',
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: tasks
|
||||
.take(5)
|
||||
.map((t) => TaskCard(
|
||||
task: t,
|
||||
onTap: () => context.go('/task/${t.id}'),
|
||||
onComplete: () =>
|
||||
_taskListBloc.add(TaskCompleted(t.id)),
|
||||
onSkip: () =>
|
||||
_taskListBloc.add(TaskSkipped(t.id)),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── FAB: create task ─────────────────────────────────────
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.go('/task-create'),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('New Task'),
|
||||
),
|
||||
|
||||
// ── Bottom nav ───────────────────────────────────────────
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: _currentNavIndex,
|
||||
onTap: _onNavTap,
|
||||
items: const [
|
||||
BottomNavigationBarItem(icon: Icon(Icons.task_alt_rounded), label: 'Tasks'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.local_fire_department_rounded), label: 'Streaks'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.timer_outlined), label: 'Time'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.settings_rounded), label: 'Settings'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Focus Mode Card ────────────────────────────────────────────────
|
||||
|
||||
class _FocusModeCard extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _FocusModeCard({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: AppColors.primary,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.self_improvement_rounded, size: 40, color: Colors.white),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Focus Mode',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Just do the next thing. One at a time.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withAlpha(200),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_forward_rounded, color: Colors.white),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ────────────────────────────────────────────────────
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
|
||||
const _EmptyState({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 56, color: AppColors.skipped),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
220
lib/features/tasks/presentation/screens/task_detail_screen.dart
Normal file
220
lib/features/tasks/presentation/screens/task_detail_screen.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:focusflow_shared/focusflow_shared.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/time_visualizer.dart';
|
||||
import '../widgets/energy_selector.dart';
|
||||
|
||||
/// Task detail / edit screen.
|
||||
///
|
||||
/// Shows title, description, energy level selector, time estimate,
|
||||
/// tags, category. Provides save and delete actions.
|
||||
class TaskDetailScreen extends StatefulWidget {
|
||||
final String taskId;
|
||||
|
||||
const TaskDetailScreen({super.key, required this.taskId});
|
||||
|
||||
@override
|
||||
State<TaskDetailScreen> createState() => _TaskDetailScreenState();
|
||||
}
|
||||
|
||||
class _TaskDetailScreenState extends State<TaskDetailScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _minutesController = TextEditingController();
|
||||
final _tagsController = TextEditingController();
|
||||
final _categoryController = TextEditingController();
|
||||
String _energyLevel = 'medium';
|
||||
|
||||
// Placeholder task — in production, load from BLoC.
|
||||
Task? _task;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTask();
|
||||
}
|
||||
|
||||
Future<void> _loadTask() async {
|
||||
// Simulated placeholder — in production the BLoC fetches from the API.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
final task = Task(
|
||||
id: widget.taskId,
|
||||
userId: 'local',
|
||||
title: 'Sample Task',
|
||||
description: 'Tap to edit this task.',
|
||||
energyLevel: 'medium',
|
||||
estimatedMinutes: 25,
|
||||
actualMinutes: 20,
|
||||
tags: const ['work', 'focus'],
|
||||
category: 'work',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_task = task;
|
||||
_loading = false;
|
||||
_titleController.text = task.title;
|
||||
_descriptionController.text = task.description ?? '';
|
||||
_minutesController.text = task.estimatedMinutes?.toString() ?? '';
|
||||
_tagsController.text = task.tags.join(', ');
|
||||
_categoryController.text = task.category ?? '';
|
||||
_energyLevel = task.energyLevel;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_minutesController.dispose();
|
||||
_tagsController.dispose();
|
||||
_categoryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
// TODO: dispatch update via BLoC.
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Task saved.')),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
void _delete() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete task?'),
|
||||
content: const Text('This cannot be undone.'),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
// TODO: dispatch delete via BLoC.
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('Delete', style: TextStyle(color: AppColors.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (_loading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Task')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Task Detail'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline_rounded, color: AppColors.error),
|
||||
onPressed: _delete,
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Title ──────────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Give it a name' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Description ────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(labelText: 'Description (optional)'),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Energy level ───────────────────────────────────
|
||||
Text('Energy level', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
EnergySelector(
|
||||
value: _energyLevel,
|
||||
onChanged: (v) => setState(() => _energyLevel = v),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Time estimate ──────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _minutesController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Estimated minutes',
|
||||
suffixText: 'min',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Time visualizer (if there is actual data) ──────
|
||||
if (_task != null &&
|
||||
_task!.estimatedMinutes != null &&
|
||||
_task!.actualMinutes != null)
|
||||
TimeVisualizer(
|
||||
estimatedMinutes: _task!.estimatedMinutes!,
|
||||
actualMinutes: _task!.actualMinutes,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Tags ───────────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Tags (comma separated)',
|
||||
prefixIcon: Icon(Icons.label_outline),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Category ───────────────────────────────────────
|
||||
TextFormField(
|
||||
controller: _categoryController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Category',
|
||||
prefixIcon: Icon(Icons.category_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── Save button ────────────────────────────────────
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
81
lib/features/tasks/presentation/widgets/energy_selector.dart
Normal file
81
lib/features/tasks/presentation/widgets/energy_selector.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
/// Three-option energy selector with icons.
|
||||
///
|
||||
/// Horizontal toggle: Low | Medium | High
|
||||
class EnergySelector extends StatelessWidget {
|
||||
final String value; // 'low', 'medium', 'high'
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const EnergySelector({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
static const _options = [
|
||||
_EnergyOption(key: 'low', label: 'Low', icon: Icons.spa_outlined, color: AppColors.energyLow),
|
||||
_EnergyOption(key: 'medium', label: 'Medium', icon: Icons.bolt_outlined, color: AppColors.energyMedium),
|
||||
_EnergyOption(key: 'high', label: 'High', icon: Icons.local_fire_department_outlined, color: AppColors.energyHigh),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: _options.map((opt) {
|
||||
final selected = value == opt.key;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(opt.key),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? opt.color.withAlpha(40) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected ? opt.color : Colors.grey.withAlpha(60),
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(opt.icon, color: selected ? opt.color : Colors.grey, size: 26),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
opt.label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? opt.color : Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EnergyOption {
|
||||
final String key;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
const _EnergyOption({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/time_visualizer.dart';
|
||||
|
||||
/// Time perception tools dashboard.
|
||||
///
|
||||
/// - Accuracy trend chart (fl_chart) showing estimate vs actual over time.
|
||||
/// - "You tend to underestimate by X%" insight.
|
||||
/// - Recent time entries list.
|
||||
/// - Tips for improving time awareness.
|
||||
class TimeDashboardScreen extends StatelessWidget {
|
||||
const TimeDashboardScreen({super.key});
|
||||
|
||||
// ── Dummy data for the chart ─────────────────────────────────────
|
||||
static const _estimatedData = [15.0, 20.0, 10.0, 30.0, 25.0, 15.0, 20.0];
|
||||
static const _actualData = [22.0, 18.0, 14.0, 40.0, 28.0, 20.0, 19.0];
|
||||
static const _labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
double get _averageBias {
|
||||
double total = 0;
|
||||
for (int i = 0; i < _estimatedData.length; i++) {
|
||||
total += (_actualData[i] - _estimatedData[i]) / _estimatedData[i];
|
||||
}
|
||||
return (total / _estimatedData.length) * 100;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bias = _averageBias;
|
||||
final biasText = bias > 0
|
||||
? 'You tend to underestimate by ${bias.abs().toStringAsFixed(0)}%'
|
||||
: 'You tend to overestimate by ${bias.abs().toStringAsFixed(0)}%';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
title: const Text('Time Perception'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Insight card ─────────────────────────────────────────
|
||||
Card(
|
||||
color: AppColors.primaryLight.withAlpha(30),
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.insights_rounded, size: 32, color: AppColors.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(biasText, style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Trend chart ──────────────────────────────────────────
|
||||
Text('Estimated vs Actual (this week)', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: const FlGridData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 32,
|
||||
getTitlesWidget: (value, meta) => Text(
|
||||
'${value.toInt()}m',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final idx = value.toInt();
|
||||
if (idx < 0 || idx >= _labels.length) return const SizedBox.shrink();
|
||||
return Text(_labels[idx], style: theme.textTheme.bodySmall);
|
||||
},
|
||||
),
|
||||
),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineBarsData: [
|
||||
// Estimated
|
||||
LineChartBarData(
|
||||
spots: List.generate(
|
||||
_estimatedData.length,
|
||||
(i) => FlSpot(i.toDouble(), _estimatedData[i]),
|
||||
),
|
||||
isCurved: true,
|
||||
color: AppColors.primary,
|
||||
barWidth: 3,
|
||||
dotData: const FlDotData(show: true),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: AppColors.primary.withAlpha(20),
|
||||
),
|
||||
),
|
||||
// Actual
|
||||
LineChartBarData(
|
||||
spots: List.generate(
|
||||
_actualData.length,
|
||||
(i) => FlSpot(i.toDouble(), _actualData[i]),
|
||||
),
|
||||
isCurved: true,
|
||||
color: AppColors.secondary,
|
||||
barWidth: 3,
|
||||
dotData: const FlDotData(show: true),
|
||||
dashArray: [6, 4],
|
||||
),
|
||||
],
|
||||
minY: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Legend ────────────────────────────────────────────────
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_LegendDot(color: AppColors.primary, label: 'Estimated'),
|
||||
const SizedBox(width: 20),
|
||||
_LegendDot(color: AppColors.secondary, label: 'Actual'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Recent entries ───────────────────────────────────────
|
||||
Text('Recent', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...List.generate(
|
||||
_estimatedData.length,
|
||||
(i) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TimeVisualizer(
|
||||
estimatedMinutes: _estimatedData[i].toInt(),
|
||||
actualMinutes: _actualData[i].toInt(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Tips ─────────────────────────────────────────────────
|
||||
Text('Tips for improving time awareness', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
const _TipCard(
|
||||
icon: Icons.timer_outlined,
|
||||
text: 'Before starting, say your estimate out loud — it makes you more committed.',
|
||||
),
|
||||
const _TipCard(
|
||||
icon: Icons.visibility_rounded,
|
||||
text: 'Use a visible clock or timer while working to build a sense of passing time.',
|
||||
),
|
||||
const _TipCard(
|
||||
icon: Icons.edit_note_rounded,
|
||||
text: 'After each task, note how long it really took. Patterns will emerge!',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendDot extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
const _LegendDot({required this.color, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TipCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
const _TipCard({required this.icon, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: AppColors.surfaceVariantLight,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: AppColors.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: theme.textTheme.bodyMedium)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
/// Non-anxiety-inducing timer widget.
|
||||
///
|
||||
/// Design rules:
|
||||
/// - Circular progress (NOT countdown — progress forward).
|
||||
/// - Soft pulsing animation.
|
||||
/// - Color stays calm (teal/blue).
|
||||
/// - No alarming sounds or red colors.
|
||||
/// - Shows "X min so far" not "X min remaining."
|
||||
class GentleTimer extends StatefulWidget {
|
||||
/// Total elapsed seconds.
|
||||
final int elapsedSeconds;
|
||||
|
||||
/// Optional estimated total in seconds (used only for the progress ring).
|
||||
final int? estimatedTotalSeconds;
|
||||
|
||||
/// Widget size (width & height).
|
||||
final double size;
|
||||
|
||||
const GentleTimer({
|
||||
super.key,
|
||||
required this.elapsedSeconds,
|
||||
this.estimatedTotalSeconds,
|
||||
this.size = 180,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GentleTimer> createState() => _GentleTimerState();
|
||||
}
|
||||
|
||||
class _GentleTimerState extends State<GentleTimer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _pulseController;
|
||||
late final Animation<double> _pulse;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat(reverse: true);
|
||||
_pulse = Tween<double>(begin: 0.96, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatElapsed() {
|
||||
final m = widget.elapsedSeconds ~/ 60;
|
||||
final s = widget.elapsedSeconds % 60;
|
||||
if (m == 0) return '${s}s so far';
|
||||
return '${m}m ${s.toString().padLeft(2, '0')}s so far';
|
||||
}
|
||||
|
||||
double get _progress {
|
||||
final total = widget.estimatedTotalSeconds;
|
||||
if (total == null || total == 0) {
|
||||
// No estimate — use a slow modular fill so the ring still moves.
|
||||
return (widget.elapsedSeconds % 300) / 300;
|
||||
}
|
||||
return (widget.elapsedSeconds / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _pulse,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _pulse.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: CustomPaint(
|
||||
painter: _GentleTimerPainter(
|
||||
progress: _progress,
|
||||
color: AppColors.primary,
|
||||
backgroundColor: AppColors.primary.withAlpha(25),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_formatElapsed(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GentleTimerPainter extends CustomPainter {
|
||||
final double progress;
|
||||
final Color color;
|
||||
final Color backgroundColor;
|
||||
|
||||
_GentleTimerPainter({
|
||||
required this.progress,
|
||||
required this.color,
|
||||
required this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = (size.shortestSide / 2) - 10;
|
||||
const strokeWidth = 10.0;
|
||||
const startAngle = -math.pi / 2;
|
||||
|
||||
// Background circle
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
radius,
|
||||
Paint()
|
||||
..color = backgroundColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
|
||||
// Progress arc
|
||||
if (progress > 0) {
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
startAngle,
|
||||
2 * math.pi * progress,
|
||||
false,
|
||||
Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _GentleTimerPainter oldDelegate) =>
|
||||
progress != oldDelegate.progress;
|
||||
}
|
||||
Reference in New Issue
Block a user