diff --git a/lib/app.dart b/lib/app.dart index ef50671..135d78f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -15,6 +15,7 @@ import 'providers/update_provider.dart'; import 'providers/tasks_provider.dart'; import 'screens/auth/login_screen.dart'; import 'screens/briefing/briefing_screen.dart'; +import 'screens/library/project_tasks_screen.dart'; import 'screens/chat/chat_screen.dart'; import 'screens/chat/conversations_tab_screen.dart'; import 'screens/library/library_screen.dart'; @@ -107,6 +108,12 @@ final routerProvider = Provider((ref) { taskId: int.parse(state.pathParameters['id']!), ), ), + GoRoute( + path: Routes.projectTasks, + builder: (_, state) => ProjectTasksScreen( + projectId: int.parse(state.pathParameters['id']!), + ), + ), GoRoute( path: Routes.chat, builder: (_, state) => ChatScreen( diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 5400dac..9d84bf1 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -16,4 +16,5 @@ abstract class Routes { static const settings = '/settings'; static const briefing = '/briefing'; static const library = '/library'; + static const projectTasks = '/projects/:id/tasks'; } diff --git a/lib/data/api/tasks_api.dart b/lib/data/api/tasks_api.dart index bb098b8..a0837c0 100644 --- a/lib/data/api/tasks_api.dart +++ b/lib/data/api/tasks_api.dart @@ -52,6 +52,24 @@ class TasksApi { } } + Future> getByProject(int projectId) async { + try { + final response = await _dio.get( + '/api/notes', + queryParameters: { + 'project_id': projectId, + 'is_task': 'true', + 'limit': 500, + }, + ); + final data = response.data as Map; + final list = data['notes'] as List; + return list.map((e) => Task.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + Future> getSubTasks(int parentId) async { try { final response = await _dio.get( diff --git a/lib/data/repositories/tasks_repository.dart b/lib/data/repositories/tasks_repository.dart index 6b509e4..c8f3abe 100644 --- a/lib/data/repositories/tasks_repository.dart +++ b/lib/data/repositories/tasks_repository.dart @@ -27,6 +27,7 @@ class TasksRepository { parentId: parentId, ); + Future> getByProject(int projectId) => _api.getByProject(projectId); Future> getSubTasks(int parentId) => _api.getSubTasks(parentId); Future update(int id, Map fields) => diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index b9555d4..9ad6b42 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -6,6 +6,11 @@ import 'api_client_provider.dart'; final tasksProvider = AsyncNotifierProvider>(TasksNotifier.new); +final projectTasksProvider = + FutureProvider.family, int>((ref, projectId) { + return ref.watch(tasksRepositoryProvider).getByProject(projectId); +}); + class TasksNotifier extends AsyncNotifier> { @override Future> build() async { diff --git a/lib/screens/library/project_tasks_screen.dart b/lib/screens/library/project_tasks_screen.dart new file mode 100644 index 0000000..b21bbf5 --- /dev/null +++ b/lib/screens/library/project_tasks_screen.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/constants.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'; + +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)); + } + } + + Color _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return const Color(0xFF6366F1); + try { + return Color(int.parse(hex.replaceFirst('#', '0xFF'))); + } catch (_) { + return const Color(0xFF6366F1); + } + } + + @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, + ), + ], + ), + ), + 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(Icons.check_box_outlined, + 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()); + ref.invalidate(projectTasksProvider(widget.projectId)); + ref.invalidate(projectMilestonesProvider(widget.projectId)); + }, + 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), + ); + }, + ), + ], + + // 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), + ); + }, + ), + ], + + 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; + + const _TaskRow({ + required this.task, + required this.effectiveStatus, + required this.onStatusTap, + }); + + IconData get _statusIcon => switch (effectiveStatus) { + TaskStatus.done => Icons.check_circle, + TaskStatus.inProgress => Icons.timelapse, + TaskStatus.todo => Icons.radio_button_unchecked, + }; + + 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: () => context + .push(Routes.taskEdit.replaceFirst(':id', '${task.id}')), + 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: [ + 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, + ), + 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}'; +} diff --git a/lib/widgets/library_item_card.dart b/lib/widgets/library_item_card.dart index bfa29d0..ef0a970 100644 --- a/lib/widgets/library_item_card.dart +++ b/lib/widgets/library_item_card.dart @@ -210,7 +210,8 @@ class ProjectLibraryCard extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () {}, // No project detail screen in this app + onTap: () => context + .push('/projects/${project.id}/tasks'), borderRadius: BorderRadius.circular(14), child: Row( children: [