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,154 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/programmer_project.model.dart';
import '../../models/programmer_task.model.dart';
import '../../providers/programmer_projects_provider.dart';
import '../../providers/programmer_tasks_provider.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/app_section_header.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../programmer_tasks/programmer_task_create.dart';
import '../programmer_tasks/programmer_task_status_kind.dart';
class ProgrammerProjectDetailScreen extends ConsumerWidget {
const ProgrammerProjectDetailScreen({super.key, required this.projectId});
final String projectId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final project = ref.watch(programmerProjectByIdProvider(projectId));
final tasks = (ref.watch(programmerTasksProvider).valueOrNull ?? const [])
.where((t) => t.projectId == projectId)
.toList();
return Scaffold(
appBar: AppBar(
title: Text(project?.name ?? 'Project'),
actions: [
if (project != null)
IconButton(
tooltip: project.isArchived ? 'Unarchive' : 'Archive',
icon: Icon(project.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined),
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
await ref.read(programmerProjectsControllerProvider).setArchived(
projectId: projectId,
archived: !project.isArchived,
);
messenger.showSnackBar(SnackBar(
content: Text(
project.isArchived ? 'Project unarchived' : 'Project archived'),
));
},
),
],
),
floatingActionButton: project == null
? null
: FloatingActionButton.extended(
heroTag: 'project_new_task',
onPressed: () => showCreateProgrammerTaskDialog(
context,
ref,
initialProjectId: projectId,
),
icon: const Icon(Icons.add),
label: const Text('New Task'),
),
body: project == null
? const Center(child: CircularProgressIndicator())
: ResponsiveBody(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 16),
children: [
M3Card.elevated(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(project.name,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.w700)),
),
StatusPill.kind(
project.isArchived
? StatusKind.neutral
: StatusKind.success,
label: ProgrammerProjectStatus.label(
project.status),
),
],
),
if (project.description != null &&
project.description!.trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(project.description!,
style: Theme.of(context).textTheme.bodyMedium),
],
],
),
),
),
const SizedBox(height: 24),
AppSectionHeader('Tasks (${tasks.length})'),
if (tasks.isEmpty)
Text('No tasks in this project yet.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant))
else
for (final t in tasks) _ProjectTaskTile(task: t),
const SizedBox(height: 80),
],
),
),
);
}
}
class _ProjectTaskTile extends StatelessWidget {
const _ProjectTaskTile({required this.task});
final ProgrammerTask task;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: M3Card.outlined(
onTap: () => context.go('/programmer-tasks/${task.id}'),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Text(task.title,
style: Theme.of(context).textTheme.bodyLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
StatusPill.kind(
programmerTaskStatusKind(task.status),
label: ProgrammerTaskStatus.label(task.status),
),
],
),
),
),
);
}
}
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/programmer_project.model.dart';
import '../../providers/programmer_projects_provider.dart';
import '../../providers/programmer_tasks_provider.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/m3_motion.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/reconnect_overlay.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
class ProgrammerProjectsListScreen extends ConsumerWidget {
const ProgrammerProjectsListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final projectsAsync = ref.watch(programmerProjectsProvider);
final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? const [];
final taskCountByProject = <String, int>{};
for (final t in tasks) {
if (t.projectId != null) {
taskCountByProject[t.projectId!] =
(taskCountByProject[t.projectId!] ?? 0) + 1;
}
}
return Stack(
children: [
ResponsiveBody(
maxWidth: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'Projects',
subtitle: 'Group programmer tasks by project',
),
Expanded(
child: Builder(
builder: (context) {
if (projectsAsync.hasError && !projectsAsync.hasValue) {
return AppErrorView(
error: projectsAsync.error!,
onRetry: () =>
ref.invalidate(programmerProjectsProvider),
);
}
if (!projectsAsync.hasValue && projectsAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final projects = projectsAsync.valueOrNull ?? const [];
if (projects.isEmpty) {
return const AppEmptyView(
icon: Icons.folder_outlined,
title: 'No projects yet',
subtitle: 'Create one with the + button, or add a '
'project on the fly from a task.',
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: projects.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final p = projects[i];
return _ProjectCard(
project: p,
taskCount: taskCountByProject[p.id] ?? 0,
);
},
);
},
),
),
],
),
),
Positioned(
right: 16,
bottom: 16,
child: M3ExpandedFab(
heroTag: 'create_programmer_project',
onPressed: () => _showCreateDialog(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Project'),
),
),
const ReconnectIndicator(),
],
);
}
Future<void> _showCreateDialog(BuildContext context, WidgetRef ref) async {
final nameCtrl = TextEditingController();
final descCtrl = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'New project',
title: const Text('New Project'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 12),
TextField(
controller: descCtrl,
minLines: 2,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration:
const InputDecoration(labelText: 'Description (optional)'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Create'),
),
],
),
);
final name = nameCtrl.text.trim();
final description = descCtrl.text.trim();
nameCtrl.dispose();
descCtrl.dispose();
if (ok != true || name.isEmpty || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
final id = await ref.read(programmerProjectsControllerProvider).createProject(
name: name,
description: description.isEmpty ? null : description,
);
if (!context.mounted) return;
context.go('/programmer-projects/$id');
} catch (e) {
messenger
.showSnackBar(SnackBar(content: Text('Could not create project: $e')));
}
}
}
class _ProjectCard extends StatelessWidget {
const _ProjectCard({required this.project, required this.taskCount});
final ProgrammerProject project;
final int taskCount;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return M3Card.outlined(
onTap: () => context.go('/programmer-projects/${project.id}'),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(project.name,
style: tt.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
StatusPill.kind(
project.isArchived ? StatusKind.neutral : StatusKind.success,
label: ProgrammerProjectStatus.label(project.status),
),
],
),
if (project.description != null &&
project.description!.trim().isNotEmpty) ...[
const SizedBox(height: 6),
Text(project.description!,
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 8),
Text('$taskCount task${taskCount == 1 ? '' : 's'}',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
],
),
),
);
}
}