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,103 @@
import 'package:flutter/material.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task_activity_log.model.dart';
import '../../../utils/app_time.dart';
import '../../../widgets/app_section_header.dart';
/// The task's activity timeline (created/started/paused/adjusted/…), newest first.
class ActivityTimeline extends StatelessWidget {
const ActivityTimeline({
super.key,
required this.logs,
required this.profileById,
});
final List<ProgrammerTaskActivityLog> logs;
final Map<String, Profile> profileById;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionHeader('Activity'),
if (logs.isEmpty)
Text('No activity yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
else
for (final log in logs)
_ActivityRow(log: log, profileById: profileById),
],
);
}
}
class _ActivityRow extends StatelessWidget {
const _ActivityRow({required this.log, required this.profileById});
final ProgrammerTaskActivityLog log;
final Map<String, Profile> profileById;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final actor = log.actorId == null ? null : profileById[log.actorId];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_iconFor(log.actionType), size: 16, color: cs.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${actor?.fullName ?? 'Someone'} · ${_labelFor(log.actionType)}',
style: tt.bodyMedium,
),
Text(
AppTime.relative(log.createdAt),
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
),
),
],
),
);
}
}
String _labelFor(String action) => switch (action) {
'created' => 'created the task',
'started' => 'started work',
'paused' => 'paused',
'resumed' => 'resumed',
'completed' => 'completed the task',
'cancelled' => 'cancelled the task',
'assigned' => 'assigned the task',
'reassigned' => 'reassigned the task',
'adjusted' || 'adjustment' => 'adjusted time',
'updated' => 'updated details',
_ => action,
};
IconData _iconFor(String action) => switch (action) {
'created' => Icons.add_task,
'started' => Icons.play_arrow_rounded,
'paused' => Icons.pause_rounded,
'resumed' => Icons.play_arrow_rounded,
'completed' => Icons.check_circle_outline,
'cancelled' => Icons.cancel_outlined,
'assigned' || 'reassigned' => Icons.person_add_alt_1_outlined,
'adjustment' => Icons.timelapse_outlined,
'updated' => Icons.edit_outlined,
_ => Icons.circle_outlined,
};
@@ -0,0 +1,280 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../models/programmer_task_comment.model.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_task_comments_provider.dart';
import '../../../utils/app_time.dart';
import '../../../widgets/app_section_header.dart';
import '../../../widgets/m3_card.dart';
import '../../../widgets/quill_field.dart';
import '../programmer_task_prompts.dart';
const _imageExts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
bool _isImage(String name) =>
_imageExts.contains(name.split('.').last.toLowerCase());
/// Comment thread + composer. Any user who can see the task may comment and
/// attach files. The assignee is prompted to pause after commenting.
class CommentSection extends ConsumerStatefulWidget {
const CommentSection({super.key, required this.task});
final ProgrammerTask task;
@override
ConsumerState<CommentSection> createState() => _CommentSectionState();
}
class _CommentSectionState extends ConsumerState<CommentSection> {
final _bodyQuill = quillControllerFromDelta(null);
final List<CommentUpload> _pending = [];
bool _sending = false;
@override
void dispose() {
_bodyQuill.dispose();
super.dispose();
}
Future<void> _pickFiles() async {
final messenger = ScaffoldMessenger.of(context);
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
withData: true,
allowMultiple: true,
);
if (result == null) return;
for (final f in result.files) {
if (f.bytes == null) continue;
if (f.bytes!.length > 25 * 1024 * 1024) {
messenger.showSnackBar(
SnackBar(content: Text('${f.name} exceeds 25MB — skipped')),
);
continue;
}
_pending.add((name: f.name, bytes: f.bytes!));
}
if (mounted) setState(() {});
}
Future<void> _send() async {
final hasText = quillPlainText(_bodyQuill).isNotEmpty;
if ((!hasText && _pending.isEmpty) || _sending) return;
setState(() => _sending = true);
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(programmerTaskCommentsControllerProvider).addComment(
taskId: widget.task.id,
body: hasText ? quillToDeltaJson(_bodyQuill) : '',
files: List.of(_pending),
);
_bodyQuill.clear();
_pending.clear();
if (!mounted) return;
await maybePromptPauseTimer(context, ref, widget.task);
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Could not send: $e')));
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final commentsAsync =
ref.watch(programmerTaskCommentsProvider(widget.task.id));
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
final profileById = {for (final p in profiles) p.id: p};
final comments = commentsAsync.valueOrNull ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionHeader('Comments'),
// Composer
QuillFieldEditor(
controller: _bodyQuill,
placeholder: 'Add a comment…',
height: 110,
),
if (_pending.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var i = 0; i < _pending.length; i++)
InputChip(
label: Text(_pending[i].name,
overflow: TextOverflow.ellipsis),
onDeleted: () => setState(() => _pending.removeAt(i)),
),
],
),
],
const SizedBox(height: 8),
Row(
children: [
IconButton(
tooltip: 'Attach files',
onPressed: _sending ? null : _pickFiles,
icon: const Icon(Icons.attach_file),
),
const Spacer(),
FilledButton.tonalIcon(
onPressed: _sending ? null : _send,
icon: _sending
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send, size: 18),
label: const Text('Send'),
),
],
),
const SizedBox(height: 12),
if (comments.isEmpty)
Text('No comments yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
else
for (final c in comments)
_CommentTile(comment: c, author: profileById[c.authorId]),
],
);
}
}
class _CommentTile extends ConsumerWidget {
const _CommentTile({required this.comment, required this.author});
final ProgrammerTaskComment comment;
final Profile? author;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final controller = ref.read(programmerTaskCommentsControllerProvider);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: M3Card.outlined(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(author?.fullName ?? 'Someone',
style: tt.bodyMedium
?.copyWith(fontWeight: FontWeight.w600)),
),
Text(AppTime.relative(comment.createdAt),
style:
tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
],
),
if (comment.body.trim().isNotEmpty) ...[
const SizedBox(height: 4),
QuillReadOnly(comment.body, style: tt.bodyMedium),
],
if (comment.attachments.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final a in comment.attachments)
_AttachmentChip(
attachment: a,
url: controller.attachmentUrl(a.path),
),
],
),
],
],
),
),
),
);
}
}
class _AttachmentChip extends StatelessWidget {
const _AttachmentChip({required this.attachment, required this.url});
final ProgrammerCommentAttachment attachment;
final String url;
@override
Widget build(BuildContext context) {
if (_isImage(attachment.name)) {
return InkWell(
onTap: () => _openImage(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
url,
width: 84,
height: 84,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _fileChip(context),
),
),
);
}
return _fileChip(context);
}
Widget _fileChip(BuildContext context) => ActionChip(
avatar: const Icon(Icons.insert_drive_file_outlined, size: 16),
label: Text(attachment.name, overflow: TextOverflow.ellipsis),
onPressed: () => _showUrl(context),
);
void _openImage(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
InteractiveViewer(child: Image.network(url)),
Padding(
padding: const EdgeInsets.all(8),
child: Text(attachment.name,
style: Theme.of(context).textTheme.bodySmall),
),
],
),
),
);
}
void _showUrl(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'Attachment link',
title: Text(attachment.name),
content: SelectableText(url),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
}
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import '../../../models/profile.model.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_section_header.dart';
import '../../../widgets/m3_card.dart';
/// Per-contributor time breakdown for a task: the assignee's running duration
/// plus each helper's logged minutes, and the total.
class TimeLedgerCard extends StatelessWidget {
const TimeLedgerCard({
super.key,
required this.ledger,
required this.profileById,
});
final TaskTimeLedger ledger;
final Map<String, Profile> profileById;
@override
Widget build(BuildContext context) {
if (ledger.total == Duration.zero) return const SizedBox.shrink();
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return M3Card.outlined(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSectionHeader(
'Time ledger',
trailing: Text(
formatDurationHm(ledger.total),
style: tt.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
for (final entry in ledger.all)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
entry.isAssignee
? Icons.play_circle_outline
: Icons.volunteer_activism_outlined,
size: 16,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
profileById[entry.userId]?.fullName ?? 'Unknown',
style: tt.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
if (entry.isAssignee)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text('assignee',
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant)),
),
Text(
formatDurationHm(entry.duration),
style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_task_work_logs_provider.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_section_header.dart';
import '../../../widgets/quill_field.dart';
import '../programmer_task_prompts.dart';
/// Work-log list + composer. The assignee logs progress notes (no time field);
/// a non-assignee helper gets Hours + Minutes fields and is prompted to deduct
/// that time from their own running task. The assignee is prompted to pause
/// after logging.
class WorkLogSection extends ConsumerStatefulWidget {
const WorkLogSection({super.key, required this.task, required this.isAssignee});
final ProgrammerTask task;
final bool isAssignee;
@override
ConsumerState<WorkLogSection> createState() => _WorkLogSectionState();
}
class _WorkLogSectionState extends ConsumerState<WorkLogSection> {
final _descQuill = quillControllerFromDelta(null);
final _hoursCtrl = TextEditingController();
final _minutesCtrl = TextEditingController();
bool _saving = false;
@override
void dispose() {
_descQuill.dispose();
_hoursCtrl.dispose();
_minutesCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_saving) return;
if (quillPlainText(_descQuill).isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Describe what you worked on')),
);
return;
}
int? minutes;
if (!widget.isAssignee) {
final h = int.tryParse(_hoursCtrl.text.trim()) ?? 0;
final m = int.tryParse(_minutesCtrl.text.trim()) ?? 0;
minutes = h * 60 + m;
if (minutes <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter the time you spent helping')),
);
return;
}
}
setState(() => _saving = true);
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(programmerTaskWorkLogsControllerProvider).addWorkLog(
taskId: widget.task.id,
description: quillToDeltaJson(_descQuill),
minutes: minutes,
);
_descQuill.clear();
_hoursCtrl.clear();
_minutesCtrl.clear();
if (!mounted) return;
if (widget.isAssignee) {
await maybePromptPauseTimer(context, ref, widget.task);
} else if (minutes != null) {
await maybePromptDeductHelperTime(context, ref, minutes, widget.task.id);
}
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Could not save: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final logsAsync = ref.watch(programmerTaskWorkLogsProvider(widget.task.id));
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
final profileById = {for (final p in profiles) p.id: p};
final logs = logsAsync.valueOrNull ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionHeader('Work Log'),
QuillFieldEditor(
controller: _descQuill,
placeholder: widget.isAssignee
? 'What did you work on?'
: 'What did you help with?',
height: 120,
),
const SizedBox(height: 8),
Row(
children: [
if (!widget.isAssignee) ...[
SizedBox(
width: 78,
child: TextField(
controller: _hoursCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Hours', isDense: true),
),
),
const SizedBox(width: 8),
SizedBox(
width: 88,
child: TextField(
controller: _minutesCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Minutes', isDense: true),
),
),
],
const Spacer(),
FilledButton.tonalIcon(
onPressed: _saving ? null : _submit,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add, size: 18),
label: const Text('Log'),
),
],
),
const SizedBox(height: 12),
if (logs.isEmpty)
Text('No work logged yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
else
for (final log in logs)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
profileById[log.authorId]?.fullName ?? 'Someone',
style: tt.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
if (log.minutes != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
formatDurationHm(Duration(minutes: log.minutes!)),
style: tt.bodySmall?.copyWith(
color: cs.primary,
fontWeight: FontWeight.w700),
),
),
Text(AppTime.relative(log.createdAt),
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant)),
],
),
const SizedBox(height: 2),
QuillReadOnly(log.description, style: tt.bodyMedium),
],
),
),
],
);
}
}