Add milestone data layer and redesign Projects tab

Milestone data layer:
- lib/data/models/milestone.dart: Milestone model with progress fields
  (total, completed, pct) from backend progress endpoint
- lib/data/api/milestones_api.dart: CRUD client for
  /api/projects/:id/milestones
- lib/data/repositories/milestones_repository.dart: thin repository wrapper
- lib/providers/milestones_provider.dart: FutureProvider.family keyed by
  project ID — lazily fetched per project when expanded
- api_client_provider.dart: registers milestonesApiProvider and
  milestonesRepositoryProvider

Projects tab redesign (project_list_screen.dart):
- Each project is now an ExpansionTile showing open task count in subtitle
- Expanding a project fetches its milestones and groups tasks under milestone
  headers with inline progress bar (completed/total count)
- Tasks show status icon, due date with overdue highlighting, priority dot
- 'No milestone' section at bottom for unassigned tasks
- Tapping a task navigates to the task editor
- Long-press on project title opens archive/complete/delete bottom sheet
- 'New task' button at bottom of each expanded project section

Bump version to 26.03.02+1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 21:33:45 -05:00
parent fceae5529d
commit 868cb0e49e
7 changed files with 508 additions and 34 deletions
+348 -33
View File
@@ -1,9 +1,15 @@
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});
@@ -36,15 +42,15 @@ class ProjectListScreen extends ConsumerWidget {
final other =
projects.where((p) => p.status != 'active').toList();
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.only(bottom: 88),
children: [
if (active.isNotEmpty) ...[
_SectionHeader(title: 'Active (${active.length})'),
...active.map((p) => _ProjectTile(project: p)),
...active.map((p) => _ProjectExpansionTile(project: p)),
],
if (other.isNotEmpty) ...[
_SectionHeader(title: 'Other'),
...other.map((p) => _ProjectTile(project: p)),
...other.map((p) => _ProjectExpansionTile(project: p)),
],
],
);
@@ -75,6 +81,8 @@ class ProjectListScreen extends ConsumerWidget {
}
}
// ─── Section header ────────────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@@ -94,37 +102,28 @@ class _SectionHeader extends StatelessWidget {
}
}
class _ProjectTile extends ConsumerWidget {
// ─── Project expansion tile ────────────────────────────────────────────────────
class _ProjectExpansionTile extends ConsumerStatefulWidget {
final Project project;
const _ProjectTile({required this.project});
const _ProjectExpansionTile({required this.project});
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusColor = switch (project.status) {
'completed' => Colors.green,
'archived' => Colors.grey,
_ => Theme.of(context).colorScheme.primary,
};
ConsumerState<_ProjectExpansionTile> createState() =>
_ProjectExpansionTileState();
}
return ListTile(
leading: CircleAvatar(
backgroundColor: statusColor.withValues(alpha: 0.15),
child: Icon(Icons.folder_outlined, color: statusColor, size: 20),
),
title: Text(project.title),
subtitle: project.description != null && project.description!.isNotEmpty
? Text(
project.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: _StatusChip(status: project.status),
onLongPress: () => _showOptions(context, ref),
);
}
class _ProjectExpansionTileState
extends ConsumerState<_ProjectExpansionTile> {
bool _expanded = false;
void _showOptions(BuildContext context, WidgetRef ref) {
Color _statusColor(BuildContext context) => switch (widget.project.status) {
'completed' => Colors.green,
'archived' => Colors.grey,
_ => Theme.of(context).colorScheme.primary,
};
void _showOptions(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
@@ -137,7 +136,7 @@ class _ProjectTile extends ConsumerWidget {
onTap: () async {
Navigator.pop(context);
await ref.read(projectsProvider.notifier).updateProject(
project.id,
widget.project.id,
{'status': 'completed'},
);
},
@@ -148,7 +147,7 @@ class _ProjectTile extends ConsumerWidget {
onTap: () async {
Navigator.pop(context);
await ref.read(projectsProvider.notifier).updateProject(
project.id,
widget.project.id,
{'status': 'archived'},
);
},
@@ -165,7 +164,7 @@ class _ProjectTile extends ConsumerWidget {
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete project?'),
content: Text(
content: const Text(
'Notes and tasks will be unlinked, not deleted.'),
actions: [
TextButton(
@@ -184,7 +183,7 @@ class _ProjectTile extends ConsumerWidget {
if (confirm == true && context.mounted) {
await ref
.read(projectsProvider.notifier)
.delete(project.id);
.delete(widget.project.id);
}
},
),
@@ -193,8 +192,322 @@ class _ProjectTile extends ConsumerWidget {
),
);
}
@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<Task> 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<int?, List<Task>> grouped = {};
for (final task in unfinishedTasks) {
grouped.putIfAbsent(task.milestoneId, () => []).add(task);
}
final widgets = <Widget>[];
// 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<Color>(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),
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});
@@ -217,6 +530,8 @@ class _StatusChip extends StatelessWidget {
}
}
// ─── Create project dialog ─────────────────────────────────────────────────────
class _CreateProjectDialog extends StatefulWidget {
final Future<void> Function(String title, String? description, String? goal)
onCreate;