Add PM extension (Projects, Work Logs, Comments) + Quill rich-text UI

Projects: Brick-backed model with list/detail screens and nav entry.
Work Logs: per-contributor time ledger, assignee running-clock +
helper logged minutes with Hours/Minutes input; pause-after-save and
helper-deduct prompts.
Comments: threaded comments with file attachments and Quill composer.
Detail screen: tabbed layout (Work Log / Comments / Activity),
editable title + description via edit dialog, Quill-rendered
descriptions with legacy plain-text fallback.
Create dialog: fixed-width (480px max), Quill description editor.
Shared QuillFieldEditor + QuillReadOnly widgets extracted for reuse.
RLS: hardened activity-log actor binding, immutable creator_id/
created_at triggers, SECURITY INVOKER visibility helper,
append-only work-log and scoped comment policies.
Brick migration for project_id FK on programmer_tasks.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-27 16:06:26 +08:00
parent 627645056b
commit b307f66f2c
33 changed files with 2960 additions and 216 deletions
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/providers/programmer_task_comments_provider.dart';
import 'package:tasq/providers/programmer_task_work_logs_provider.dart';
import 'package:tasq/providers/programmer_tasks_provider.dart';
import 'package:tasq/screens/programmer_tasks/programmer_task_actions.dart';
import 'package:tasq/utils/app_time.dart';
@@ -31,6 +33,8 @@ class _FakeClient {
final Map<String, List<Map<String, dynamic>>> tables = {
'programmer_tasks': [],
'programmer_task_activity_logs': [],
'programmer_task_work_logs': [],
'programmer_task_comments': [],
};
_FakeAuth get auth => _FakeAuth(userId);
@@ -459,4 +463,79 @@ void main() {
expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.queued);
});
});
group('projects, adjustments & collaboration', () {
test('createTask carries project_id', () async {
final fake = _FakeClient('u1');
final c = ProgrammerTasksController(fake as dynamic);
final id = await c.createTask(
title: 't',
category: ProgrammerTaskCategory.others,
projectId: 'proj-1',
);
expect(_task(fake, id)['project_id'], 'proj-1');
});
test('setProject updates project_id and logs an update', () async {
final fake = _FakeClient('u1');
final c = ProgrammerTasksController(fake as dynamic);
final id = await c.createTask(
title: 't',
category: ProgrammerTaskCategory.others,
);
await c.setProject(taskId: id, projectId: 'proj-9');
expect(_task(fake, id)['project_id'], 'proj-9');
expect(
_logs(fake).any((l) =>
l['action_type'] == 'updated' &&
(l['meta']?['fields'] as List?)?.contains('project_id') == true),
isTrue,
);
});
test('addAdjustment records a signed adjustment log', () async {
final fake = _FakeClient('u1');
final c = ProgrammerTasksController(fake as dynamic);
final id = await c.createTask(
title: 't',
category: ProgrammerTaskCategory.others,
);
await c.addAdjustment(
taskId: id,
seconds: 1800,
reason: 'helped',
sourceTaskId: 'other',
);
final adj = _logs(fake).firstWhere((l) => l['action_type'] == 'adjustment');
expect(adj['meta']['seconds'], 1800);
expect(adj['meta']['reason'], 'helped');
expect(adj['meta']['source_task_id'], 'other');
});
test('addWorkLog stores helper minutes and omits them for the assignee',
() async {
final fake = _FakeClient('u1');
final wl = ProgrammerTaskWorkLogsController(fake as dynamic);
await wl.addWorkLog(taskId: 't1', description: 'helped', minutes: 30);
await wl.addWorkLog(taskId: 't1', description: 'progress'); // assignee note
final rows = fake.tables['programmer_task_work_logs']!;
expect(rows.length, 2);
expect(rows[0]['author_id'], 'u1');
expect(rows[0]['minutes'], 30);
expect(rows[1].containsKey('minutes'), isFalse); // null minutes omitted
});
test('addComment stores body + author with empty attachments', () async {
final fake = _FakeClient('u1');
final cc = ProgrammerTaskCommentsController(fake as dynamic);
await cc.addComment(taskId: 't1', body: 'looks good');
final rows = fake.tables['programmer_task_comments']!;
expect(rows.length, 1);
expect(rows.first['author_id'], 'u1');
expect(rows.first['body'], 'looks good');
expect(rows.first['attachments'], isEmpty);
});
});
}