From 868cb0e49ede64fd7e4caad0e9f99c51bd9fb2a0 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 2 Mar 2026 21:33:45 -0500 Subject: [PATCH] Add milestone data layer and redesign Projects tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/data/api/milestones_api.dart | 71 ++++ lib/data/models/milestone.dart | 41 ++ .../repositories/milestones_repository.dart | 26 ++ lib/providers/api_client_provider.dart | 10 + lib/providers/milestones_provider.dart | 11 + lib/screens/projects/project_list_screen.dart | 381 ++++++++++++++++-- pubspec.yaml | 2 +- 7 files changed, 508 insertions(+), 34 deletions(-) create mode 100644 lib/data/api/milestones_api.dart create mode 100644 lib/data/models/milestone.dart create mode 100644 lib/data/repositories/milestones_repository.dart create mode 100644 lib/providers/milestones_provider.dart diff --git a/lib/data/api/milestones_api.dart b/lib/data/api/milestones_api.dart new file mode 100644 index 0000000..1a45315 --- /dev/null +++ b/lib/data/api/milestones_api.dart @@ -0,0 +1,71 @@ +import 'package:dio/dio.dart'; + +import '../models/milestone.dart'; +import 'api_client.dart'; + +class MilestonesApi { + final Dio _dio; + const MilestonesApi(this._dio); + + Future> getAll(int projectId, {String? status}) async { + try { + final response = await _dio.get( + '/api/projects/$projectId/milestones', + queryParameters: status != null ? {'status': status} : null, + ); + final data = response.data as Map; + final list = data['milestones'] as List; + return list + .map((e) => Milestone.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future create( + int projectId, { + required String title, + String? description, + int orderIndex = 0, + }) async { + try { + final response = await _dio.post( + '/api/projects/$projectId/milestones', + data: { + 'title': title, + if (description != null && description.isNotEmpty) + 'description': description, + 'order_index': orderIndex, + }, + ); + return Milestone.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future update( + int projectId, + int milestoneId, + Map fields, + ) async { + try { + final response = await _dio.patch( + '/api/projects/$projectId/milestones/$milestoneId', + data: fields, + ); + return Milestone.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future delete(int projectId, int milestoneId) async { + try { + await _dio.delete('/api/projects/$projectId/milestones/$milestoneId'); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/models/milestone.dart b/lib/data/models/milestone.dart new file mode 100644 index 0000000..ff91550 --- /dev/null +++ b/lib/data/models/milestone.dart @@ -0,0 +1,41 @@ +class Milestone { + final int id; + final int projectId; + final String title; + final String? description; + final String status; // active | completed | archived + final int orderIndex; + final int total; + final int completed; + final double pct; + final DateTime createdAt; + final DateTime updatedAt; + + const Milestone({ + required this.id, + required this.projectId, + required this.title, + this.description, + required this.status, + required this.orderIndex, + required this.total, + required this.completed, + required this.pct, + required this.createdAt, + required this.updatedAt, + }); + + factory Milestone.fromJson(Map json) => Milestone( + id: json['id'] as int, + projectId: json['project_id'] as int, + title: json['title'] as String? ?? '', + description: json['description'] as String?, + status: json['status'] as String? ?? 'active', + orderIndex: json['order_index'] as int? ?? 0, + total: json['total'] as int? ?? 0, + completed: json['completed'] as int? ?? 0, + pct: (json['pct'] as num?)?.toDouble() ?? 0.0, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); +} diff --git a/lib/data/repositories/milestones_repository.dart b/lib/data/repositories/milestones_repository.dart new file mode 100644 index 0000000..7b14b77 --- /dev/null +++ b/lib/data/repositories/milestones_repository.dart @@ -0,0 +1,26 @@ +import '../api/milestones_api.dart'; +import '../models/milestone.dart'; + +class MilestonesRepository { + final MilestonesApi _api; + const MilestonesRepository(this._api); + + Future> getAll(int projectId, {String? status}) => + _api.getAll(projectId, status: status); + + Future create( + int projectId, { + required String title, + String? description, + int orderIndex = 0, + }) => + _api.create(projectId, + title: title, description: description, orderIndex: orderIndex); + + Future update( + int projectId, int milestoneId, Map fields) => + _api.update(projectId, milestoneId, fields); + + Future delete(int projectId, int milestoneId) => + _api.delete(projectId, milestoneId); +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index fe9ce0b..fe95f61 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -5,12 +5,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/api/api_client.dart'; import '../data/api/auth_api.dart'; import '../data/api/chat_api.dart'; +import '../data/api/milestones_api.dart'; import '../data/api/notes_api.dart'; import '../data/api/projects_api.dart'; import '../data/api/quick_capture_api.dart'; import '../data/api/tasks_api.dart'; import '../data/repositories/auth_repository.dart'; import '../data/repositories/chat_repository.dart'; +import '../data/repositories/milestones_repository.dart'; import '../data/repositories/notes_repository.dart'; import '../data/repositories/projects_repository.dart'; import '../data/repositories/tasks_repository.dart'; @@ -70,3 +72,11 @@ final chatRepositoryProvider = Provider((ref) { final projectsRepositoryProvider = Provider((ref) { return ProjectsRepository(ref.watch(projectsApiProvider)); }); + +final milestonesApiProvider = Provider((ref) { + return MilestonesApi(ref.watch(dioProvider)); +}); + +final milestonesRepositoryProvider = Provider((ref) { + return MilestonesRepository(ref.watch(milestonesApiProvider)); +}); diff --git a/lib/providers/milestones_provider.dart b/lib/providers/milestones_provider.dart new file mode 100644 index 0000000..9fc70ad --- /dev/null +++ b/lib/providers/milestones_provider.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/milestone.dart'; +import 'api_client_provider.dart'; + +/// Fetches active milestones for a given project ID. +/// Keyed by projectId so each project gets its own cached list. +final projectMilestonesProvider = + FutureProvider.family, int>((ref, projectId) { + return ref.watch(milestonesRepositoryProvider).getAll(projectId); +}); diff --git a/lib/screens/projects/project_list_screen.dart b/lib/screens/projects/project_list_screen.dart index 71eaaa0..0e85e71 100644 --- a/lib/screens/projects/project_list_screen.dart +++ b/lib/screens/projects/project_list_screen.dart @@ -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( 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 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> grouped = {}; + for (final task in unfinishedTasks) { + grouped.putIfAbsent(task.milestoneId, () => []).add(task); + } + + final widgets = []; + + // 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(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 Function(String title, String? description, String? goal) onCreate; diff --git a/pubspec.yaml b/pubspec.yaml index 9366847..bada420 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: fabled_app description: "FabledAssistant mobile client for Android." publish_to: 'none' -version: 26.03.01+1 +version: 26.03.02+1 environment: sdk: ^3.11.0