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
@@ -0,0 +1,122 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/programmer_project.model.dart';
import 'profile_provider.dart';
import 'realtime_controller.dart';
import 'stream_recovery.dart';
import 'supabase_provider.dart';
void _sortProjects(List<ProgrammerProject> projects) {
projects.sort((a, b) {
// Active before archived, then alphabetical.
if (a.isArchived != b.isArchived) return a.isArchived ? 1 : -1;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
}
/// All programmer projects (offline-readable via Brick cache).
final programmerProjectsProvider = StreamProvider<List<ProgrammerProject>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ProgrammerProject>(
stream: client
.from('programmer_projects')
.stream(primaryKey: ['id']).order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('programmer_projects')
.select()
.order('created_at', ascending: false);
return data.map(ProgrammerProject.fromMap).toList();
},
fromMap: ProgrammerProject.fromMap,
channelName: 'programmer_projects',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ProgrammerProject>();
_sortProjects(all);
return all;
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ProgrammerProject>(rows, tag: 'programmer_projects'),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) {
final list = List<ProgrammerProject>.from(result.data);
_sortProjects(list);
return list;
});
});
/// Active projects only — used by the task form's project picker.
final activeProgrammerProjectsProvider = Provider<List<ProgrammerProject>>((ref) {
final all = ref.watch(programmerProjectsProvider).valueOrNull ?? const [];
return all.where((p) => !p.isArchived).toList();
});
final programmerProjectByIdProvider =
Provider.family<ProgrammerProject?, String>((ref, id) {
final projects = ref.watch(programmerProjectsProvider).valueOrNull;
if (projects == null) return null;
try {
return projects.firstWhere((p) => p.id == id);
} catch (_) {
return null;
}
});
final programmerProjectsControllerProvider =
Provider<ProgrammerProjectsController>((ref) {
return ProgrammerProjectsController(ref.watch(supabaseClientProvider));
});
/// Projects are created/edited online (inline from the task form or the
/// projects screen). Reads fall back to the Brick cache when offline.
class ProgrammerProjectsController {
ProgrammerProjectsController(this._client);
final dynamic _client;
Future<String> createProject({
required String name,
String? description,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final id = const Uuid().v4();
await _client.from('programmer_projects').insert({
'id': id,
'name': name,
'description': ?description,
'creator_id': userId,
});
return id;
}
Future<void> updateProject({
required String projectId,
String? name,
String? description,
}) async {
final updates = <String, dynamic>{};
if (name != null) updates['name'] = name;
if (description != null) updates['description'] = description;
if (updates.isEmpty) return;
await _client.from('programmer_projects').update(updates).eq('id', projectId);
}
Future<void> setArchived({
required String projectId,
required bool archived,
}) async {
await _client.from('programmer_projects').update({
'status': archived
? ProgrammerProjectStatus.archived
: ProgrammerProjectStatus.active,
}).eq('id', projectId);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../models/programmer_task_comment.model.dart';
import 'supabase_provider.dart';
/// Bucket reused for programmer-task comment attachments (public read;
/// authenticated write; owner/privileged delete — already hardened by
/// 20260926120000_task_attachment_write_hardening.sql).
const _attachmentBucket = 'task_attachments';
/// A file selected for upload with a comment.
typedef CommentUpload = ({String name, Uint8List bytes});
/// Comments for a task (newest first), streamed live.
final programmerTaskCommentsProvider =
StreamProvider.family<List<ProgrammerTaskComment>, String>((ref, taskId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_task_comments')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false)
.map((rows) => rows.map(ProgrammerTaskComment.fromMap).toList());
});
final programmerTaskCommentsControllerProvider =
Provider<ProgrammerTaskCommentsController>((ref) {
return ProgrammerTaskCommentsController(ref.watch(supabaseClientProvider));
});
class ProgrammerTaskCommentsController {
ProgrammerTaskCommentsController(this._client);
final dynamic _client;
/// Public URL for a stored attachment path.
String attachmentUrl(String path) =>
_client.storage.from(_attachmentBucket).getPublicUrl(path);
/// Adds a comment, uploading any [files] to the attachment bucket first.
/// Requires either a non-empty [body] or at least one file.
Future<void> addComment({
required String taskId,
required String body,
List<CommentUpload> files = const [],
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final attachments = <Map<String, dynamic>>[];
for (final file in files) {
final path =
'programmer_tasks/$taskId/comments/${const Uuid().v4()}_${file.name}';
await _client.storage.from(_attachmentBucket).uploadBinary(
path,
file.bytes,
fileOptions: const FileOptions(upsert: true),
);
attachments.add({'path': path, 'name': file.name});
}
await _client.from('programmer_task_comments').insert({
'task_id': taskId,
'author_id': userId,
'body': body,
'attachments': attachments,
});
}
}
@@ -0,0 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/programmer_task_work_log.model.dart';
import 'supabase_provider.dart';
/// Work-log entries for a task (newest first), streamed live.
final programmerTaskWorkLogsProvider =
StreamProvider.family<List<ProgrammerTaskWorkLog>, String>((ref, taskId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_task_work_logs')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false)
.map((rows) => rows.map(ProgrammerTaskWorkLog.fromMap).toList());
});
final programmerTaskWorkLogsControllerProvider =
Provider<ProgrammerTaskWorkLogsController>((ref) {
return ProgrammerTaskWorkLogsController(ref.watch(supabaseClientProvider));
});
class ProgrammerTaskWorkLogsController {
ProgrammerTaskWorkLogsController(this._client);
final dynamic _client;
/// Adds a work-log entry. [minutes] is null for the assignee's progress notes
/// (their time comes from the running clock) and set for a helper's
/// Time Consumed.
Future<void> addWorkLog({
required String taskId,
required String description,
int? minutes,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
await _client.from('programmer_task_work_logs').insert({
'task_id': taskId,
'author_id': userId,
'description': description,
'minutes': ?minutes,
});
}
}
@@ -347,6 +347,7 @@ class ProgrammerTasksController {
required String category,
String? description,
String? assigneeId,
String? projectId,
int priority = 1,
}) async {
final userId = _client.auth.currentUser?.id;
@@ -363,6 +364,7 @@ class ProgrammerTasksController {
'priority': priority,
'assignee_id': assignee,
'creator_id': userId,
'project_id': ?projectId,
};
try {
@@ -384,6 +386,7 @@ class ProgrammerTasksController {
priority: priority,
assigneeId: assignee,
creatorId: userId,
projectId: projectId,
createdAt: now,
updatedAt: now,
);
@@ -481,6 +484,33 @@ class ProgrammerTasksController {
await _insertLog(taskId, 'updated', meta: {'fields': updates.keys.toList()});
}
/// Assigns the task to a project (pass null to clear it).
Future<void> setProject({
required String taskId,
required String? projectId,
}) async {
await _updateTaskRow(taskId, {'project_id': projectId});
await _insertLog(taskId, 'updated', meta: {
'fields': ['project_id'],
});
}
/// Records a time adjustment (negative [seconds]) against a task's worked
/// duration — e.g. when the current user helped on another task and chose to
/// deduct that time from their own running task.
Future<void> addAdjustment({
required String taskId,
required int seconds,
String? reason,
String? sourceTaskId,
}) async {
await _insertLog(taskId, 'adjustment', meta: {
'seconds': seconds,
'reason': ?reason,
'source_task_id': ?sourceTaskId,
});
}
/// Applies a field patch to a task row. When [onlyIfStartNull] is set,
/// `started_at` is dropped if the task already has one (preserves the first
/// execution start across pause/resume cycles).
@@ -578,6 +608,8 @@ ProgrammerTask _applyUpdateFields(ProgrammerTask t, Map<String, dynamic> f) {
assigneeId:
f.containsKey('assignee_id') ? f['assignee_id'] as String? : t.assigneeId,
creatorId: t.creatorId,
projectId:
f.containsKey('project_id') ? f['project_id'] as String? : t.projectId,
createdAt: t.createdAt,
startedAt: f.containsKey('started_at') ? parseOpt('started_at') : t.startedAt,
completedAt: