b307f66f2c
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>
239 lines
8.5 KiB
Dart
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;
|
|
}
|
|
}
|