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,238 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/programmer_project.model.dart';
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/programmer_projects_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../widgets/quill_field.dart';
|
||||
|
||||
/// Shows the "New Programmer Task" dialog and, on success, navigates to the new
|
||||
/// task's detail. Shared by the tasks list FAB and the project detail screen
|
||||
/// (pass [initialProjectId] to pre-select a project).
|
||||
Future<void> showCreateProgrammerTaskDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
String? initialProjectId,
|
||||
}) async {
|
||||
final userId = ref.read(currentUserIdProvider);
|
||||
final profiles = ref.read(profilesProvider).valueOrNull ?? const <Profile>[];
|
||||
final eligible = profiles
|
||||
.where((p) => p.role == 'programmer' || p.role == 'admin')
|
||||
.toList()
|
||||
..sort((a, b) => a.fullName.compareTo(b.fullName));
|
||||
final projectOptions =
|
||||
List<ProgrammerProject>.from(ref.read(activeProgrammerProjectsProvider));
|
||||
|
||||
final titleCtrl = TextEditingController();
|
||||
final descQuill = quillControllerFromDelta(null);
|
||||
var category = ProgrammerTaskCategory.softwareDevelopment;
|
||||
var assigneeId = userId;
|
||||
var projectId = initialProjectId;
|
||||
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final dialogWidth =
|
||||
math.min(480.0, MediaQuery.of(context).size.width - 48);
|
||||
return AlertDialog(
|
||||
semanticLabel: 'New programmer task',
|
||||
title: const Text('New Programmer Task'),
|
||||
content: SizedBox(
|
||||
width: dialogWidth,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
hintText: 'What are you working on?',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Description (optional)',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
QuillFieldEditor(
|
||||
controller: descQuill,
|
||||
placeholder: 'Describe the task…',
|
||||
height: 140,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: category,
|
||||
decoration: const InputDecoration(labelText: 'Category'),
|
||||
items: [
|
||||
for (final c in ProgrammerTaskCategory.all)
|
||||
DropdownMenuItem(value: c, child: Text(c)),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => category = v ?? category),
|
||||
),
|
||||
if (eligible.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: assigneeId,
|
||||
decoration: const InputDecoration(labelText: 'Assignee'),
|
||||
items: [
|
||||
for (final p in eligible)
|
||||
DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(
|
||||
p.id == userId ? '${p.fullName} (me)' : p.fullName,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => assigneeId = v ?? assigneeId),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String?>(
|
||||
initialValue: projectOptions.any((p) => p.id == projectId)
|
||||
? projectId
|
||||
: null,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Project (optional)'),
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null, child: Text('No project')),
|
||||
for (final p in projectOptions)
|
||||
DropdownMenuItem<String?>(
|
||||
value: p.id, child: Text(p.name)),
|
||||
],
|
||||
onChanged: (v) => setDialogState(() => projectId = v),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () async {
|
||||
final proj = await _promptNewProject(context, ref);
|
||||
if (proj != null) {
|
||||
setDialogState(() {
|
||||
projectOptions.add(proj);
|
||||
projectId = proj.id;
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('New project'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (created != true) {
|
||||
titleCtrl.dispose();
|
||||
descQuill.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final title = titleCtrl.text.trim();
|
||||
titleCtrl.dispose();
|
||||
final description =
|
||||
quillPlainText(descQuill).isEmpty ? null : quillToDeltaJson(descQuill);
|
||||
descQuill.dispose();
|
||||
if (title.isEmpty) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final id = await ref.read(programmerTasksControllerProvider).createTask(
|
||||
title: title,
|
||||
category: category,
|
||||
description: description,
|
||||
assigneeId: assigneeId,
|
||||
projectId: projectId,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
context.go('/programmer-tasks/$id');
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not create task: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts for a project name, creates it, and returns a lightweight model for
|
||||
/// the picker (the realtime insert with the same id syncs shortly after).
|
||||
Future<ProgrammerProject?> _promptNewProject(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final nameCtrl = TextEditingController();
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'New project',
|
||||
title: const Text('New project'),
|
||||
content: TextField(
|
||||
controller: nameCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(labelText: 'Project name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(nameCtrl.text.trim()),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
nameCtrl.dispose();
|
||||
if (name == null || name.isEmpty) return null;
|
||||
try {
|
||||
final id = await ref
|
||||
.read(programmerProjectsControllerProvider)
|
||||
.createProject(name: name);
|
||||
final now = DateTime.now().toUtc();
|
||||
return ProgrammerProject(
|
||||
id: id,
|
||||
name: name,
|
||||
status: ProgrammerProjectStatus.active,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Could not create project: $e')),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user