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:
@@ -5,16 +5,24 @@ import '../../models/profile.model.dart';
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../models/programmer_task_activity_log.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/programmer_projects_provider.dart';
|
||||
import '../../providers/programmer_task_work_logs_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/programmer_task_ledger.dart';
|
||||
import '../../utils/task_duration.dart';
|
||||
import '../../widgets/app_section_header.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/quill_field.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../../widgets/tech_chip.dart';
|
||||
import 'programmer_task_actions.dart';
|
||||
import 'programmer_task_edit.dart';
|
||||
import 'programmer_task_status_kind.dart';
|
||||
import 'widgets/activity_timeline.dart';
|
||||
import 'widgets/comment_section.dart';
|
||||
import 'widgets/time_ledger_card.dart';
|
||||
import 'widgets/work_log_section.dart';
|
||||
|
||||
/// Lifecycle events, newest-first, that determine the live running/paused state.
|
||||
const _lifecycleActions = ['started', 'paused', 'resumed', 'completed', 'cancelled'];
|
||||
@@ -37,12 +45,18 @@ class ProgrammerTaskDetailScreen extends ConsumerWidget {
|
||||
appBar: AppBar(
|
||||
title: Text(task?.title ?? 'Programmer Task'),
|
||||
actions: [
|
||||
if (task != null && !task.isTerminal && _canAct(task, userId, role))
|
||||
if (task != null && !task.isTerminal && _canAct(task, userId, role)) ...[
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => showEditProgrammerTaskDialog(context, ref, task),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Reassign',
|
||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||
onPressed: () => _reassign(context, ref, task, profiles, userId),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: task == null
|
||||
@@ -99,22 +113,45 @@ class _DetailBody extends ConsumerWidget {
|
||||
ProgrammerTaskStatus.cancelled => task.cancelledAt ?? AppTime.now(),
|
||||
_ => AppTime.now(),
|
||||
};
|
||||
// Sum any time deducted (helped elsewhere) from the activity log.
|
||||
var adjustmentSeconds = 0;
|
||||
for (final l in logs) {
|
||||
if (l.actionType == 'adjustment') {
|
||||
adjustmentSeconds += (l.meta?['seconds'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
}
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: task.startedAt,
|
||||
endAt: endAt,
|
||||
events: logs
|
||||
.map((l) => (createdAt: l.createdAt, actionType: l.actionType))
|
||||
.toList(),
|
||||
adjustment: Duration(seconds: adjustmentSeconds),
|
||||
);
|
||||
|
||||
final workLogs =
|
||||
ref.watch(programmerTaskWorkLogsProvider(task.id)).valueOrNull ?? const [];
|
||||
final ledger = computeTaskLedger(
|
||||
assigneeId: task.assigneeId,
|
||||
assigneeDuration: worked,
|
||||
workLogs: workLogs,
|
||||
);
|
||||
|
||||
final assignee =
|
||||
task.assigneeId == null ? null : profileById[task.assigneeId];
|
||||
final project = task.projectId == null
|
||||
? null
|
||||
: ref.watch(programmerProjectByIdProvider(task.projectId!));
|
||||
final canAct = _canAct(task, userId, role);
|
||||
final isAssignee = task.assigneeId == userId;
|
||||
|
||||
return ResponsiveBody(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
children: [
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: ResponsiveBody(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
M3Card.elevated(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -145,6 +182,8 @@ class _DetailBody extends ConsumerWidget {
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
TechChip(task.category, icon: Icons.category_outlined),
|
||||
if (project != null)
|
||||
TechChip(project.name, icon: Icons.folder_outlined),
|
||||
if (assignee != null)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -164,7 +203,7 @@ class _DetailBody extends ConsumerWidget {
|
||||
if (task.description != null &&
|
||||
task.description!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
Text(task.description!, style: tt.bodyMedium),
|
||||
QuillReadOnly(task.description!, style: tt.bodyMedium),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
@@ -172,7 +211,7 @@ class _DetailBody extends ConsumerWidget {
|
||||
Icon(Icons.timer_outlined, size: 18, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Worked: ${_fmtDuration(worked)}',
|
||||
'Worked: ${formatDurationHm(worked)}',
|
||||
style: tt.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
@@ -195,6 +234,10 @@ class _DetailBody extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (ledger.total > Duration.zero) ...[
|
||||
const SizedBox(height: 16),
|
||||
TimeLedgerCard(ledger: ledger, profileById: profileById),
|
||||
],
|
||||
if (canAct && !task.isTerminal) ...[
|
||||
const SizedBox(height: 16),
|
||||
_ActionBar(
|
||||
@@ -203,15 +246,34 @@ class _DetailBody extends ConsumerWidget {
|
||||
isPaused: isPaused,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const AppSectionHeader('Activity'),
|
||||
if (logs.isEmpty)
|
||||
Text('No activity yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
|
||||
else
|
||||
...logs.map((l) => _ActivityRow(log: l, profileById: profileById)),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Work Log'),
|
||||
Tab(text: 'Comments'),
|
||||
Tab(text: 'Activity'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: WorkLogSection(task: task, isAssignee: isAssignee),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: CommentSection(task: task),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: ActivityTimeline(logs: logs, profileById: profileById),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -277,77 +339,6 @@ class _ActionBar extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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 _fmtDuration(Duration d) {
|
||||
final h = d.inHours;
|
||||
final m = d.inMinutes.remainder(60);
|
||||
if (h > 0) return '${h}h ${m}m';
|
||||
return '${m}m';
|
||||
}
|
||||
|
||||
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',
|
||||
'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,
|
||||
'updated' => Icons.edit_outlined,
|
||||
_ => Icons.circle_outlined,
|
||||
};
|
||||
|
||||
Future<void> _complete(
|
||||
BuildContext context,
|
||||
|
||||
Reference in New Issue
Block a user