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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user