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