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
+80
View File
@@ -0,0 +1,80 @@
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
import 'package:brick_sqlite/brick_sqlite.dart';
import 'package:brick_supabase/brick_supabase.dart';
import '../utils/app_time.dart';
/// Status of a programmer project.
class ProgrammerProjectStatus {
static const active = 'active';
static const archived = 'archived';
static const all = [active, archived];
static String label(String status) {
switch (status) {
case active:
return 'Active';
case archived:
return 'Archived';
default:
return status;
}
}
}
@ConnectOfflineFirstWithSupabase(
supabaseConfig: SupabaseSerializable(tableName: 'programmer_projects'),
)
class ProgrammerProject extends OfflineFirstWithSupabaseModel {
final String id;
final String name;
final String? description;
final String status;
final String? creatorId;
final DateTime createdAt;
final DateTime updatedAt;
ProgrammerProject({
required this.id,
required this.name,
this.description,
required this.status,
this.creatorId,
required this.createdAt,
required this.updatedAt,
});
bool get isArchived => status == ProgrammerProjectStatus.archived;
@Supabase(ignore: true)
@Sqlite(ignore: true)
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProgrammerProject &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name &&
status == other.status &&
updatedAt == other.updatedAt;
@Supabase(ignore: true)
@Sqlite(ignore: true)
@override
int get hashCode => Object.hash(id, name, status, updatedAt);
factory ProgrammerProject.fromMap(Map<String, dynamic> map) {
return ProgrammerProject(
id: map['id'].toString(),
name: map['name'] as String? ?? '',
description: map['description'] as String?,
status: map['status'] as String? ?? ProgrammerProjectStatus.active,
creatorId: map['creator_id']?.toString(),
createdAt: AppTime.parse(map['created_at'] as String),
updatedAt: map['updated_at'] == null
? AppTime.parse(map['created_at'] as String)
: AppTime.parse(map['updated_at'] as String),
);
}
}
+3
View File
@@ -74,6 +74,7 @@ class ProgrammerTask extends OfflineFirstWithSupabaseModel {
final int priority;
final String? assigneeId;
final String? creatorId;
final String? projectId;
final DateTime createdAt;
final DateTime? startedAt;
final DateTime? completedAt;
@@ -90,6 +91,7 @@ class ProgrammerTask extends OfflineFirstWithSupabaseModel {
this.priority = 1,
this.assigneeId,
this.creatorId,
this.projectId,
required this.createdAt,
this.startedAt,
this.completedAt,
@@ -126,6 +128,7 @@ class ProgrammerTask extends OfflineFirstWithSupabaseModel {
priority: (map['priority'] as num?)?.toInt() ?? 1,
assigneeId: map['assignee_id']?.toString(),
creatorId: map['creator_id']?.toString(),
projectId: map['project_id']?.toString(),
createdAt: AppTime.parse(map['created_at'] as String),
startedAt: map['started_at'] == null
? null
@@ -0,0 +1,74 @@
import 'dart:convert';
import '../utils/app_time.dart';
/// One file attached to a comment (stored in the task_attachments bucket).
class ProgrammerCommentAttachment {
final String path;
final String name;
const ProgrammerCommentAttachment({required this.path, required this.name});
Map<String, dynamic> toJson() => {'path': path, 'name': name};
factory ProgrammerCommentAttachment.fromJson(Map<String, dynamic> json) {
return ProgrammerCommentAttachment(
path: json['path']?.toString() ?? '',
name: json['name']?.toString() ?? '',
);
}
}
/// A comment on a programmer task (plain, online-first model). Any user who can
/// see the task may comment and attach files, whether or not they are the
/// assignee.
class ProgrammerTaskComment {
final String id;
final String taskId;
final String authorId;
final String body;
final List<ProgrammerCommentAttachment> attachments;
final DateTime createdAt;
ProgrammerTaskComment({
required this.id,
required this.taskId,
required this.authorId,
required this.body,
this.attachments = const [],
required this.createdAt,
});
factory ProgrammerTaskComment.fromMap(Map<String, dynamic> map) {
return ProgrammerTaskComment(
id: map['id'].toString(),
taskId: map['task_id'].toString(),
authorId: map['author_id'].toString(),
body: map['body'] as String? ?? '',
attachments: _parseAttachments(map['attachments']),
createdAt: AppTime.parse(map['created_at'] as String),
);
}
static List<ProgrammerCommentAttachment> _parseAttachments(dynamic raw) {
List<dynamic> list;
if (raw is List) {
list = raw;
} else if (raw is String && raw.isNotEmpty) {
try {
final decoded = jsonDecode(raw);
list = decoded is List ? decoded : const [];
} catch (_) {
list = const [];
}
} else {
return const [];
}
return list
.whereType<Map>()
.map((e) => ProgrammerCommentAttachment.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
}
}
@@ -0,0 +1,36 @@
import '../utils/app_time.dart';
/// A work-log entry on a programmer task (plain, online-first model).
///
/// The assignee logs progress notes with [minutes] == null (their time comes
/// from the running clock). A non-assignee "helper" logs with [minutes] set —
/// their contributed time, which feeds the per-contributor time ledger and can
/// be deducted from the helper's own running task.
class ProgrammerTaskWorkLog {
final String id;
final String taskId;
final String authorId;
final String description;
final int? minutes;
final DateTime createdAt;
ProgrammerTaskWorkLog({
required this.id,
required this.taskId,
required this.authorId,
required this.description,
this.minutes,
required this.createdAt,
});
factory ProgrammerTaskWorkLog.fromMap(Map<String, dynamic> map) {
return ProgrammerTaskWorkLog(
id: map['id'].toString(),
taskId: map['task_id'].toString(),
authorId: map['author_id'].toString(),
description: map['description'] as String? ?? '',
minutes: (map['minutes'] as num?)?.toInt(),
createdAt: AppTime.parse(map['created_at'] as String),
);
}
}