import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/constants.dart'; import '../../core/exceptions.dart'; import '../../data/models/milestone.dart'; import '../../data/models/project.dart'; import '../../data/models/task.dart'; import '../../providers/milestones_provider.dart'; import '../../providers/projects_provider.dart'; import '../../providers/tasks_provider.dart'; class ProjectListScreen extends ConsumerWidget { const ProjectListScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final projectsAsync = ref.watch(projectsProvider); return Scaffold( appBar: AppBar(title: const Text('Projects')), floatingActionButton: FloatingActionButton( onPressed: () => _showCreateDialog(context, ref), tooltip: 'New project', child: const Icon(Icons.add), ), body: projectsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), data: (projects) { if (projects.isEmpty) { return const Center( child: Text( 'No projects yet.\nTap + to create one.', textAlign: TextAlign.center, ), ); } final active = projects.where((p) => p.status == 'active').toList(); final other = projects.where((p) => p.status != 'active').toList(); return ListView( padding: const EdgeInsets.only(bottom: 88), children: [ if (active.isNotEmpty) ...[ _SectionHeader(title: 'Active (${active.length})'), ...active.map((p) => _ProjectExpansionTile(project: p)), ], if (other.isNotEmpty) ...[ _SectionHeader(title: 'Other'), ...other.map((p) => _ProjectExpansionTile(project: p)), ], ], ); }, ), ); } void _showCreateDialog(BuildContext context, WidgetRef ref) { showDialog( context: context, builder: (dialogContext) => _CreateProjectDialog( onCreate: (title, description, goal) async { try { await ref .read(projectsProvider.notifier) .create(title: title, description: description, goal: goal); } on AppException catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.message)), ); } } }, ), ); } } // ─── Section header ──────────────────────────────────────────────────────────── class _SectionHeader extends StatelessWidget { final String title; const _SectionHeader({required 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 .labelMedium ?.copyWith(color: Theme.of(context).colorScheme.primary), ), ); } } // ─── Project expansion tile ──────────────────────────────────────────────────── class _ProjectExpansionTile extends ConsumerStatefulWidget { final Project project; const _ProjectExpansionTile({required this.project}); @override ConsumerState<_ProjectExpansionTile> createState() => _ProjectExpansionTileState(); } class _ProjectExpansionTileState extends ConsumerState<_ProjectExpansionTile> { bool _expanded = false; Color _statusColor(BuildContext context) => switch (widget.project.status) { 'completed' => Colors.green, 'archived' => Colors.grey, _ => Theme.of(context).colorScheme.primary, }; void _showOptions(BuildContext context) { showModalBottomSheet( context: context, builder: (_) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.check_circle_outline), title: const Text('Mark completed'), onTap: () async { Navigator.pop(context); await ref.read(projectsProvider.notifier).updateProject( widget.project.id, {'status': 'completed'}, ); }, ), ListTile( leading: const Icon(Icons.archive_outlined), title: const Text('Archive'), onTap: () async { Navigator.pop(context); await ref.read(projectsProvider.notifier).updateProject( widget.project.id, {'status': 'archived'}, ); }, ), ListTile( leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), title: Text('Delete', style: TextStyle( color: Theme.of(context).colorScheme.error)), onTap: () async { Navigator.pop(context); final confirm = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Delete project?'), content: const Text( 'Notes and tasks will be unlinked, not deleted.'), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.pop(dialogContext, true), child: const Text('Delete'), ), ], ), ); if (confirm == true && context.mounted) { await ref .read(projectsProvider.notifier) .delete(widget.project.id); } }, ), ], ), ), ); } @override Widget build(BuildContext context) { final statusColor = _statusColor(context); final tasksAsync = ref.watch(tasksProvider); final unfinished = tasksAsync.valueOrNull ?.where((t) => t.projectId == widget.project.id && t.status != TaskStatus.done) .toList() ?? []; final subtitle = unfinished.isEmpty ? (widget.project.description != null && widget.project.description!.isNotEmpty ? widget.project.description! : null) : '${unfinished.length} task${unfinished.length == 1 ? '' : 's'} in progress'; return ExpansionTile( key: PageStorageKey('project-${widget.project.id}'), initiallyExpanded: false, onExpansionChanged: (v) => setState(() => _expanded = v), leading: CircleAvatar( backgroundColor: statusColor.withValues(alpha: 0.15), child: Icon(Icons.folder_outlined, color: statusColor, size: 20), ), title: GestureDetector( onLongPress: () => _showOptions(context), child: Text(widget.project.title), ), subtitle: subtitle != null ? Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ) : null, trailing: _StatusChip(status: widget.project.status), children: [ if (_expanded) _ProjectTaskList( project: widget.project, unfinishedTasks: unfinished, ), ], ); } } // ─── Expanded task list grouped by milestone ─────────────────────────────────── class _ProjectTaskList extends ConsumerWidget { final Project project; final List unfinishedTasks; const _ProjectTaskList({ required this.project, required this.unfinishedTasks, }); @override Widget build(BuildContext context, WidgetRef ref) { final milestonesAsync = ref.watch(projectMilestonesProvider(project.id)); return milestonesAsync.when( loading: () => const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ), error: (e, _) => Padding( padding: const EdgeInsets.all(16), child: Text('Error loading milestones: $e', style: TextStyle( color: Theme.of(context).colorScheme.error)), ), data: (milestones) { // Only active milestones in order final activeMilestones = milestones .where((m) => m.status == 'active') .toList() ..sort((a, b) => a.orderIndex.compareTo(b.orderIndex)); if (unfinishedTasks.isEmpty) { return _EmptyProjectContent(project: project); } // Build milestone → tasks map final Map> grouped = {}; for (final task in unfinishedTasks) { grouped.putIfAbsent(task.milestoneId, () => []).add(task); } final widgets = []; // Milestone groups (in order) for (final ms in activeMilestones) { final tasks = grouped[ms.id]; if (tasks == null || tasks.isEmpty) continue; widgets.add(_MilestoneHeader(milestone: ms)); for (final task in tasks) { widgets.add(_TaskRow(task: task)); } } // No-milestone group final noMilestoneTasks = grouped[null] ?? []; if (noMilestoneTasks.isNotEmpty) { if (activeMilestones.isNotEmpty) { widgets.add(const _NoMilestoneHeader()); } for (final task in noMilestoneTasks) { widgets.add(_TaskRow(task: task)); } } widgets.add(_AddTaskRow(project: project)); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: widgets, ); }, ); } } class _EmptyProjectContent extends StatelessWidget { final Project project; const _EmptyProjectContent({required this.project}); @override Widget build(BuildContext context) { return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Text( 'No open tasks.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.outline, ), ), ), _AddTaskRow(project: project), ], ); } } // ─── Milestone header ────────────────────────────────────────────────────────── class _MilestoneHeader extends StatelessWidget { final Milestone milestone; const _MilestoneHeader({required this.milestone}); @override Widget build(BuildContext context) { final pct = milestone.total == 0 ? 0.0 : milestone.pct / 100.0; final colorScheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(56, 12, 16, 2), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.flag_outlined, size: 14), const SizedBox(width: 4), Expanded( child: Text( milestone.title, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: colorScheme.secondary, fontWeight: FontWeight.w600, ), ), ), Text( '${milestone.completed}/${milestone.total}', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: colorScheme.outline, ), ), ], ), const SizedBox(height: 4), LinearProgressIndicator( value: pct, minHeight: 3, borderRadius: BorderRadius.circular(2), backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.4), valueColor: AlwaysStoppedAnimation(colorScheme.secondary), ), ], ), ); } } class _NoMilestoneHeader extends StatelessWidget { const _NoMilestoneHeader(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(56, 12, 16, 2), child: Text( 'No milestone', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.outline, ), ), ); } } // ─── Task row ────────────────────────────────────────────────────────────────── class _TaskRow extends StatelessWidget { final Task task; const _TaskRow({required this.task}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isInProgress = task.status == TaskStatus.inProgress; final dotColor = isInProgress ? colorScheme.primary : colorScheme.outline; final priorityColor = switch (task.priority) { TaskPriority.high => Colors.red, TaskPriority.medium => Colors.orange, TaskPriority.low => Colors.blue, _ => null, }; return ListTile( dense: true, contentPadding: const EdgeInsets.fromLTRB(56, 0, 16, 0), leading: Icon( isInProgress ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 18, color: dotColor, ), title: Text(task.title, maxLines: 2, overflow: TextOverflow.ellipsis), subtitle: task.dueDate != null ? Text( _formatDue(task.dueDate!), style: TextStyle( fontSize: 11, color: _isDueOverdue(task.dueDate!) ? colorScheme.error : colorScheme.outline, ), ) : null, trailing: priorityColor != null ? Container( width: 6, height: 6, decoration: BoxDecoration( color: priorityColor, shape: BoxShape.circle, ), ) : null, onTap: () => context.push( Routes.taskEdit.replaceFirst(':id', '${task.id}'), ), ); } String _formatDue(DateTime due) { final now = DateTime.now(); final diff = due.difference(DateTime(now.year, now.month, now.day)).inDays; if (diff == 0) return 'Due today'; if (diff == 1) return 'Due tomorrow'; if (diff < 0) return 'Overdue ${(-diff)} day${(-diff) == 1 ? '' : 's'}'; if (diff < 7) return 'Due in $diff days'; return 'Due ${due.month}/${due.day}'; } bool _isDueOverdue(DateTime due) { final now = DateTime.now(); return due.isBefore(DateTime(now.year, now.month, now.day)); } } // ─── Add task row ────────────────────────────────────────────────────────────── class _AddTaskRow extends StatelessWidget { final Project project; const _AddTaskRow({required this.project}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(48, 4, 16, 8), child: TextButton.icon( onPressed: () => context.push('${Routes.taskNew}?projectId=${project.id}'), icon: const Icon(Icons.add, size: 16), label: const Text('New task'), style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.outline, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), textStyle: const TextStyle(fontSize: 13), ), ), ); } } // ─── Status chip ─────────────────────────────────────────────────────────────── class _StatusChip extends StatelessWidget { final String status; const _StatusChip({required this.status}); @override Widget build(BuildContext context) { final (label, color) = switch (status) { 'completed' => ('Done', Colors.green), 'archived' => ('Archived', Colors.grey), _ => ('Active', Theme.of(context).colorScheme.primary), }; return Chip( label: Text(label, style: const TextStyle(fontSize: 11)), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, side: BorderSide(color: color.withValues(alpha: 0.4)), backgroundColor: color.withValues(alpha: 0.1), labelStyle: TextStyle(color: color), ); } } // ─── Create project dialog ───────────────────────────────────────────────────── class _CreateProjectDialog extends StatefulWidget { final Future Function(String title, String? description, String? goal) onCreate; const _CreateProjectDialog({required this.onCreate}); @override State<_CreateProjectDialog> createState() => _CreateProjectDialogState(); } class _CreateProjectDialogState extends State<_CreateProjectDialog> { final _titleController = TextEditingController(); final _descController = TextEditingController(); final _goalController = TextEditingController(); bool _saving = false; @override void dispose() { _titleController.dispose(); _descController.dispose(); _goalController.dispose(); super.dispose(); } Future _submit() async { final title = _titleController.text.trim(); if (title.isEmpty) return; setState(() => _saving = true); try { await widget.onCreate( title, _descController.text.trim().isEmpty ? null : _descController.text.trim(), _goalController.text.trim().isEmpty ? null : _goalController.text.trim(), ); if (mounted) Navigator.pop(context); } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('New Project'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _titleController, decoration: const InputDecoration( labelText: 'Title', border: OutlineInputBorder(), ), autofocus: true, textInputAction: TextInputAction.next, ), const SizedBox(height: 12), TextField( controller: _descController, decoration: const InputDecoration( labelText: 'Description (optional)', border: OutlineInputBorder(), ), maxLines: 2, textInputAction: TextInputAction.next, ), const SizedBox(height: 12), TextField( controller: _goalController, decoration: const InputDecoration( labelText: 'Goal (optional)', border: OutlineInputBorder(), ), textInputAction: TextInputAction.done, onSubmitted: (_) => _submit(), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: _saving ? null : _submit, child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Create'), ), ], ); } }