import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/constants.dart'; import '../../data/local/database.dart'; import '../../data/models/milestone.dart'; import '../../data/models/project.dart'; import '../../data/models/task.dart'; import '../../providers/api_client_provider.dart'; import '../../providers/milestones_provider.dart'; import '../../providers/projects_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../widgets/pending_sync_badge.dart'; class ProjectTasksScreen extends ConsumerStatefulWidget { final int projectId; const ProjectTasksScreen({super.key, required this.projectId}); @override ConsumerState createState() => _ProjectTasksScreenState(); } class _ProjectTasksScreenState extends ConsumerState { // Local optimistic status overrides — avoids a reload flash on every cycle tap. final Map _pendingStatus = {}; TaskStatus _effectiveStatus(Task task) => _pendingStatus[task.id] ?? task.status; TaskStatus _nextStatus(TaskStatus s) => switch (s) { TaskStatus.todo => TaskStatus.inProgress, TaskStatus.inProgress => TaskStatus.done, TaskStatus.done => TaskStatus.todo, }; Future _cycleStatus(Task task) async { final current = _effectiveStatus(task); final next = _nextStatus(current); setState(() => _pendingStatus[task.id] = next); try { await ref .read(tasksRepositoryProvider) .update(task.id, {'status': next.value}); // Sync the global tasks list so the library view stays consistent. ref.invalidate(tasksProvider); } catch (_) { // Revert optimistic change on error. setState(() => _pendingStatus.remove(task.id)); } } void _openTask(int taskId) { context .push(Routes.taskEdit.replaceFirst(':id', '$taskId')) .then((_) => ref.invalidate(projectTasksProvider(widget.projectId))); } Color _parseColor(String? hex) { if (hex == null || hex.isEmpty) return const Color(0xFF5B4A8A); try { return Color(int.parse(hex.replaceFirst('#', '0xFF'))); } catch (_) { return const Color(0xFF5B4A8A); } } @override Widget build(BuildContext context) { final tasksAsync = ref.watch(projectTasksProvider(widget.projectId)); final milestonesAsync = ref.watch(projectMilestonesProvider(widget.projectId)); final project = ref.watch(projectsProvider).value ?.whereType() .where((p) => p.id == widget.projectId) .firstOrNull; final color = _parseColor(project?.color); return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( project?.title ?? 'Project', style: Theme.of(context).textTheme.titleLarge, ), if (project?.description?.isNotEmpty == true) Text( project!.description!, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), actions: [ IconButton( icon: const Icon(LucideIcons.pencil), tooltip: 'Edit project', onPressed: () => context.push('/projects/${widget.projectId}/edit'), ), ], ), body: tasksAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error loading tasks: $e')), data: (tasks) => _buildBody(context, tasks, milestonesAsync, color), ), ); } Widget _buildBody( BuildContext context, List tasks, AsyncValue> milestonesAsync, Color color, ) { final milestones = (milestonesAsync.value ?? []).toList() ..sort((a, b) => a.orderIndex.compareTo(b.orderIndex)); // Group tasks by milestoneId. final byMilestone = >{}; for (final t in tasks) { (byMilestone[t.milestoneId] ??= []).add(t); } final unassigned = byMilestone[null] ?? []; if (tasks.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.checkSquare, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(height: 12), Text( 'No tasks in this project yet.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ); } return RefreshIndicator( onRefresh: () async { setState(() => _pendingStatus.clear()); await Future.wait([ ref.refresh(projectTasksProvider(widget.projectId).future), ref.refresh(projectMilestonesProvider(widget.projectId).future), ]); }, child: CustomScrollView( slivers: [ // Top colour strip. SliverToBoxAdapter(child: Container(height: 4, color: color)), // Milestone sections. for (final ms in milestones) ...[ SliverToBoxAdapter( child: _MilestoneHeader( milestone: ms, tasks: byMilestone[ms.id] ?? [], color: color, ), ), if ((byMilestone[ms.id] ?? []).isNotEmpty) SliverList.builder( itemCount: byMilestone[ms.id]!.length, itemBuilder: (_, i) { final task = byMilestone[ms.id]![i]; return _TaskRow( task: task, effectiveStatus: _effectiveStatus(task), onStatusTap: () => _cycleStatus(task), onTap: () => _openTask(task.id), ); }, ), ], // Unassigned tasks. if (unassigned.isNotEmpty) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), child: Text( 'No milestone', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, letterSpacing: 0.5, ), ), ), ), SliverList.builder( itemCount: unassigned.length, itemBuilder: (_, i) { final task = unassigned[i]; return _TaskRow( task: task, effectiveStatus: _effectiveStatus(task), onStatusTap: () => _cycleStatus(task), onTap: () => _openTask(task.id), ); }, ), ], const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), ); } } // ── Milestone section header ────────────────────────────────────────────────── class _MilestoneHeader extends StatelessWidget { final Milestone milestone; final List tasks; final Color color; const _MilestoneHeader({ required this.milestone, required this.tasks, required this.color, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final done = tasks.where((t) => t.status == TaskStatus.done).length; final total = tasks.length; final pct = total > 0 ? done / total : 0.0; return Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 8), Expanded( child: Text( milestone.title, style: theme.textTheme.titleSmall, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Text( '$done / $total', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), if (total > 0) ...[ const SizedBox(height: 4), LinearProgressIndicator( value: pct, minHeight: 2, color: color, backgroundColor: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(1), ), ], ], ), ); } } // ── Task row ────────────────────────────────────────────────────────────────── class _TaskRow extends StatelessWidget { final Task task; final TaskStatus effectiveStatus; final VoidCallback onStatusTap; final VoidCallback onTap; const _TaskRow({ required this.task, required this.effectiveStatus, required this.onStatusTap, required this.onTap, }); IconData get _statusIcon => switch (effectiveStatus) { TaskStatus.done => LucideIcons.checkCircle2, TaskStatus.inProgress => LucideIcons.loader, TaskStatus.todo => LucideIcons.circle, }; Color _statusColor(BuildContext context) { final cs = Theme.of(context).colorScheme; return switch (effectiveStatus) { TaskStatus.done => const Color(0xFF22C55E), TaskStatus.inProgress => cs.primary, TaskStatus.todo => cs.onSurfaceVariant, }; } Color _priorityColor(BuildContext context) => switch (task.priority) { TaskPriority.high => const Color(0xFFEF4444), TaskPriority.medium => const Color(0xFFF59E0B), _ => Colors.transparent, }; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.fromLTRB(4, 6, 14, 6), child: Row( children: [ IconButton( icon: Icon(_statusIcon, color: _statusColor(context)), onPressed: onStatusTap, tooltip: 'Cycle status', visualDensity: VisualDensity.compact, ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( task.title.isNotEmpty ? task.title : 'Untitled', style: theme.textTheme.titleSmall?.copyWith( decoration: effectiveStatus == TaskStatus.done ? TextDecoration.lineThrough : null, color: effectiveStatus == TaskStatus.done ? theme.colorScheme.onSurfaceVariant : null, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), PendingSyncBadge( domain: kSyncDomainTasks, id: task.id, ), ], ), if (task.dueDate != null) ...[ const SizedBox(height: 2), Text( 'Due ${_formatDate(task.dueDate!)}', style: theme.textTheme.labelSmall?.copyWith( color: task.dueDate!.isBefore(DateTime.now()) && effectiveStatus != TaskStatus.done ? const Color(0xFFEF4444) : theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ), if (task.priority == TaskPriority.high || task.priority == TaskPriority.medium) Container( width: 8, height: 8, decoration: BoxDecoration( color: _priorityColor(context), shape: BoxShape.circle, ), ), ], ), ), ), ); } } String _formatDate(DateTime dt) { const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; return '${months[dt.month - 1]} ${dt.day}'; }