Files
tasq/lib/screens/programmer_tasks/programmer_task_create.dart
redz1029 b307f66f2c 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>
2026-09-27 16:06:26 +08:00

239 lines
8.5 KiB
Dart

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;
}
}