Project task view: tapping a project shows milestone-grouped task list

- ProjectLibraryCard.onTap navigates to /projects/:id/tasks
- New ProjectTasksScreen: milestone sections with coloured dot,
  done/total counter and thin progress bar; unassigned tasks at bottom
- Status cycle icon updates optimistically (pendingStatus map) so
  the list doesn't flash on every tap; syncs global tasksProvider
- New route /projects/:id/tasks registered in app.dart
- TasksApi.getByProject + TasksRepository + projectTasksProvider (family)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 21:40:14 -04:00
parent a337c3fda3
commit 5ca5856f15
7 changed files with 415 additions and 1 deletions
+7
View File
@@ -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<GoRouter>((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(
+1
View File
@@ -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';
}
+18
View File
@@ -52,6 +52,24 @@ class TasksApi {
}
}
Future<List<Task>> 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<String, dynamic>;
final list = data['notes'] as List<dynamic>;
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<List<Task>> getSubTasks(int parentId) async {
try {
final response = await _dio.get(
@@ -27,6 +27,7 @@ class TasksRepository {
parentId: parentId,
);
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
Future<Task> update(int id, Map<String, dynamic> fields) =>
+5
View File
@@ -6,6 +6,11 @@ import 'api_client_provider.dart';
final tasksProvider =
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
final projectTasksProvider =
FutureProvider.family<List<Task>, int>((ref, projectId) {
return ref.watch(tasksRepositoryProvider).getByProject(projectId);
});
class TasksNotifier extends AsyncNotifier<List<Task>> {
@override
Future<List<Task>> build() async {
@@ -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<ProjectTasksScreen> createState() => _ProjectTasksScreenState();
}
class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
// Local optimistic status overrides — avoids a reload flash on every cycle tap.
final Map<int, TaskStatus> _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<void> _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<Project>()
.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<Task> tasks,
AsyncValue<List<Milestone>> milestonesAsync,
Color color,
) {
final milestones = (milestonesAsync.value ?? []).toList()
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
// Group tasks by milestoneId.
final byMilestone = <int?, List<Task>>{};
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<Task> 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}';
}
+2 -1
View File
@@ -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: [