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:
@@ -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}';
|
||||
}
|
||||
Reference in New Issue
Block a user