From 3c3055d5365eb6fd89bd1b253de2936db0252c39 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 2 Mar 2026 21:59:58 -0500 Subject: [PATCH] Improve project-task integration and sub-task support - TasksApi: add getSubTasks(parentId) + parentId param to create() - TasksRepository: expose new API methods - app.dart: invalidate projectsProvider + projectMilestonesProvider after quick capture creates a task; taskNew route reads ?projectId= query param and passes as initialProjectId to TaskEditScreen - project_list_screen.dart: _AddTaskRow passes projectId query param so the task editor pre-fills the project on navigation - task_edit_screen.dart: fully rewritten with initialProjectId support, sub-task fetching/creation, and _SubTasksSection widget Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 12 +- lib/data/api/tasks_api.dart | 16 + lib/data/repositories/tasks_repository.dart | 4 + lib/screens/projects/project_list_screen.dart | 2 +- lib/screens/tasks/task_edit_screen.dart | 301 +++++++++++++----- 5 files changed, 261 insertions(+), 74 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index eb6d472..27116d9 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,7 +7,9 @@ import 'core/exceptions.dart'; import 'providers/api_client_provider.dart'; import 'providers/auth_provider.dart'; import 'providers/capture_queue_provider.dart'; +import 'providers/milestones_provider.dart'; import 'providers/notes_provider.dart'; +import 'providers/projects_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/update_provider.dart'; import 'providers/tasks_provider.dart'; @@ -94,7 +96,11 @@ final routerProvider = Provider((ref) { ), GoRoute( path: Routes.taskNew, - builder: (_, _) => const TaskEditScreen(), + builder: (_, state) => TaskEditScreen( + initialProjectId: state.uri.queryParameters['projectId'] != null + ? int.tryParse(state.uri.queryParameters['projectId']!) + : null, + ), ), GoRoute( path: Routes.taskEdit, @@ -347,6 +353,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { case 'task': case 'todo': ref.invalidate(tasksProvider); + ref.invalidate(projectsProvider); + ref.invalidate(projectMilestonesProvider); } final msg = result.message.isNotEmpty @@ -398,6 +406,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { case 'task': case 'todo': ref.invalidate(tasksProvider); + ref.invalidate(projectsProvider); + ref.invalidate(projectMilestonesProvider); } } on NetworkException { break; // Still offline — stop draining. diff --git a/lib/data/api/tasks_api.dart b/lib/data/api/tasks_api.dart index 76889db..bb098b8 100644 --- a/lib/data/api/tasks_api.dart +++ b/lib/data/api/tasks_api.dart @@ -34,6 +34,7 @@ class TasksApi { required TaskPriority priority, DateTime? dueDate, int? projectId, + int? parentId, }) async { try { final response = await _dio.post('/api/tasks', data: { @@ -43,6 +44,7 @@ class TasksApi { 'priority': priority.value, 'due_date': dueDate?.toIso8601String(), if (projectId != null) 'project_id': projectId, + if (parentId != null) 'parent_id': parentId, }); return Task.fromJson(response.data as Map); } on DioException catch (e) { @@ -50,6 +52,20 @@ class TasksApi { } } + Future> getSubTasks(int parentId) async { + try { + final response = await _dio.get( + '/api/notes', + queryParameters: {'parent_id': parentId, 'is_task': 'true', 'limit': 100}, + ); + 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 update(int id, Map fields) async { try { final response = await _dio.put('/api/tasks/$id', data: fields); diff --git a/lib/data/repositories/tasks_repository.dart b/lib/data/repositories/tasks_repository.dart index 6f04534..6b509e4 100644 --- a/lib/data/repositories/tasks_repository.dart +++ b/lib/data/repositories/tasks_repository.dart @@ -15,6 +15,7 @@ class TasksRepository { required TaskPriority priority, DateTime? dueDate, int? projectId, + int? parentId, }) => _api.create( title: title, @@ -23,8 +24,11 @@ class TasksRepository { priority: priority, dueDate: dueDate, projectId: projectId, + parentId: parentId, ); + Future> getSubTasks(int parentId) => _api.getSubTasks(parentId); + Future update(int id, Map fields) => _api.update(id, fields); diff --git a/lib/screens/projects/project_list_screen.dart b/lib/screens/projects/project_list_screen.dart index 0e85e71..27facf1 100644 --- a/lib/screens/projects/project_list_screen.dart +++ b/lib/screens/projects/project_list_screen.dart @@ -493,7 +493,7 @@ class _AddTaskRow extends StatelessWidget { return Padding( padding: const EdgeInsets.fromLTRB(48, 4, 16, 8), child: TextButton.icon( - onPressed: () => context.push(Routes.taskNew), + onPressed: () => context.push('${Routes.taskNew}?projectId=${project.id}'), icon: const Icon(Icons.add, size: 16), label: const Text('New task'), style: TextButton.styleFrom( diff --git a/lib/screens/tasks/task_edit_screen.dart b/lib/screens/tasks/task_edit_screen.dart index 0e42f4f..6993ed6 100644 --- a/lib/screens/tasks/task_edit_screen.dart +++ b/lib/screens/tasks/task_edit_screen.dart @@ -2,7 +2,9 @@ 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/api/tasks_api.dart'; import '../../data/models/task.dart'; import '../../providers/api_client_provider.dart'; import '../../providers/tasks_provider.dart'; @@ -10,7 +12,8 @@ import '../../widgets/project_selector.dart'; class TaskEditScreen extends ConsumerStatefulWidget { final int? taskId; - const TaskEditScreen({super.key, this.taskId}); + final int? initialProjectId; + const TaskEditScreen({super.key, this.taskId, this.initialProjectId}); @override ConsumerState createState() => _TaskEditScreenState(); @@ -25,13 +28,14 @@ class _TaskEditScreenState extends ConsumerState { DateTime? _dueDate; int? _projectId; bool _saving = false; + List _subTasks = []; - // Future is created once in initState so FutureBuilder never restarts it. late final Future _initFuture; @override void initState() { super.initState(); + _projectId = widget.initialProjectId; _initFuture = widget.taskId != null ? _loadExisting() : Future.value(); } @@ -44,14 +48,18 @@ class _TaskEditScreenState extends ConsumerState { } Future _loadExisting() async { - final task = - await ref.read(tasksRepositoryProvider).getOne(widget.taskId!); + final repo = ref.read(tasksRepositoryProvider); + final task = await repo.getOne(widget.taskId!); _titleController.text = task.title; _descController.text = task.description ?? ''; _status = task.status; _priority = task.priority; _dueDate = task.dueDate; _projectId = task.projectId; + // Fetch sub-tasks + final api = ref.read(tasksApiProvider); + final subs = await api.getSubTasks(widget.taskId!); + setState(() => _subTasks = subs); } Future _save() async { @@ -124,6 +132,54 @@ class _TaskEditScreenState extends ConsumerState { if (date != null) setState(() => _dueDate = date); } + Future _addSubTask() async { + final titleCtrl = TextEditingController(); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Add sub-task'), + content: TextField( + controller: titleCtrl, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.done, + onSubmitted: (v) => Navigator.pop(ctx, v.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel')), + FilledButton( + onPressed: () => + Navigator.pop(ctx, titleCtrl.text.trim()), + child: const Text('Add')), + ], + ), + ); + titleCtrl.dispose(); + if (result == null || result.isEmpty || !mounted) return; + try { + final api = ref.read(tasksApiProvider); + final sub = await api.create( + title: result, + status: TaskStatus.todo, + priority: TaskPriority.none, + projectId: _projectId, + parentId: widget.taskId, + ); + ref.invalidate(tasksProvider); + setState(() => _subTasks = [..._subTasks, sub]); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } + } + @override Widget build(BuildContext context) { return FutureBuilder( @@ -160,75 +216,88 @@ class _TaskEditScreenState extends ConsumerState { key: _formKey, child: ListView( padding: const EdgeInsets.all(16), - children: [ - TextFormField( - controller: _titleController, - decoration: const InputDecoration( - labelText: 'Title', - border: OutlineInputBorder(), - ), - validator: (v) => - (v == null || v.trim().isEmpty) ? 'Required' : null, + children: [ + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + validator: (v) => + (v == null || v.trim().isEmpty) + ? 'Required' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _status, + decoration: const InputDecoration( + labelText: 'Status', + border: OutlineInputBorder(), + ), + items: TaskStatus.values + .map((s) => DropdownMenuItem( + value: s, child: Text(s.label))) + .toList(), + onChanged: (v) => + setState(() => _status = v!), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _priority, + decoration: const InputDecoration( + labelText: 'Priority', + border: OutlineInputBorder(), + ), + items: TaskPriority.values + .map((p) => DropdownMenuItem( + value: p, child: Text(p.label))) + .toList(), + onChanged: (v) => + setState(() => _priority = v!), + ), + const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_dueDate == null + ? 'No due date' + : 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'), + leading: const Icon(Icons.calendar_today), + trailing: _dueDate != null + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => + setState(() => _dueDate = null), + ) + : null, + onTap: _pickDate, + ), + const SizedBox(height: 16), + ProjectSelector( + value: _projectId, + onChanged: (id) => + setState(() => _projectId = id), + ), + // Sub-tasks (only when editing an existing task) + if (widget.taskId != null) ...[ + const SizedBox(height: 24), + _SubTasksSection( + subTasks: _subTasks, + onAdd: _addSubTask, + ), + ], + ], ), - const SizedBox(height: 16), - TextFormField( - controller: _descController, - decoration: const InputDecoration( - labelText: 'Description (optional)', - border: OutlineInputBorder(), - ), - maxLines: 3, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _status, - decoration: const InputDecoration( - labelText: 'Status', - border: OutlineInputBorder(), - ), - items: TaskStatus.values - .map((s) => DropdownMenuItem( - value: s, child: Text(s.label))) - .toList(), - onChanged: (v) => setState(() => _status = v!), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _priority, - decoration: const InputDecoration( - labelText: 'Priority', - border: OutlineInputBorder(), - ), - items: TaskPriority.values - .map((p) => DropdownMenuItem( - value: p, child: Text(p.label))) - .toList(), - onChanged: (v) => setState(() => _priority = v!), - ), - const SizedBox(height: 16), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(_dueDate == null - ? 'No due date' - : 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'), - leading: const Icon(Icons.calendar_today), - trailing: _dueDate != null - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () => - setState(() => _dueDate = null), - ) - : null, - onTap: _pickDate, - ), - const SizedBox(height: 16), - ProjectSelector( - value: _projectId, - onChanged: (id) => setState(() => _projectId = id), - ), - ], - ), - ), + ), ), ), ); @@ -236,3 +305,91 @@ class _TaskEditScreenState extends ConsumerState { ); } } + +// ─── Sub-tasks section ───────────────────────────────────────────────────────── + +class _SubTasksSection extends ConsumerWidget { + final List subTasks; + final VoidCallback onAdd; + const _SubTasksSection({required this.subTasks, required this.onAdd}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Sub-tasks', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 16), + label: const Text('Add'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + textStyle: const TextStyle(fontSize: 13), + ), + ), + ], + ), + if (subTasks.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No sub-tasks yet.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.outline, + ), + ), + ) + else + ...subTasks.map((sub) => _SubTaskTile(task: sub, ref: ref)), + ], + ); + } +} + +class _SubTaskTile extends StatelessWidget { + final Task task; + final WidgetRef ref; + const _SubTaskTile({required this.task, required this.ref}); + + @override + Widget build(BuildContext context) { + final isDone = task.status == TaskStatus.done; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Checkbox( + value: isDone, + onChanged: (_) async { + final newStatus = + isDone ? TaskStatus.todo : TaskStatus.done; + await ref.read(tasksProvider.notifier).updateTask( + task.id, {'status': newStatus.value}); + }, + ), + title: Text( + task.title, + style: TextStyle( + decoration: isDone ? TextDecoration.lineThrough : null, + color: isDone + ? Theme.of(context).colorScheme.outline + : null, + ), + ), + onTap: () => context.push( + Routes.taskEdit.replaceFirst(':id', '${task.id}'), + ), + ); + } +}