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,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/programmer_project.model.dart';
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/programmer_projects_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/app_section_header.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../programmer_tasks/programmer_task_create.dart';
|
||||
import '../programmer_tasks/programmer_task_status_kind.dart';
|
||||
|
||||
class ProgrammerProjectDetailScreen extends ConsumerWidget {
|
||||
const ProgrammerProjectDetailScreen({super.key, required this.projectId});
|
||||
|
||||
final String projectId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final project = ref.watch(programmerProjectByIdProvider(projectId));
|
||||
final tasks = (ref.watch(programmerTasksProvider).valueOrNull ?? const [])
|
||||
.where((t) => t.projectId == projectId)
|
||||
.toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(project?.name ?? 'Project'),
|
||||
actions: [
|
||||
if (project != null)
|
||||
IconButton(
|
||||
tooltip: project.isArchived ? 'Unarchive' : 'Archive',
|
||||
icon: Icon(project.isArchived
|
||||
? Icons.unarchive_outlined
|
||||
: Icons.archive_outlined),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await ref.read(programmerProjectsControllerProvider).setArchived(
|
||||
projectId: projectId,
|
||||
archived: !project.isArchived,
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
project.isArchived ? 'Project unarchived' : 'Project archived'),
|
||||
));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: project == null
|
||||
? null
|
||||
: FloatingActionButton.extended(
|
||||
heroTag: 'project_new_task',
|
||||
onPressed: () => showCreateProgrammerTaskDialog(
|
||||
context,
|
||||
ref,
|
||||
initialProjectId: projectId,
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Task'),
|
||||
),
|
||||
body: project == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ResponsiveBody(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
children: [
|
||||
M3Card.elevated(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(project.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
StatusPill.kind(
|
||||
project.isArchived
|
||||
? StatusKind.neutral
|
||||
: StatusKind.success,
|
||||
label: ProgrammerProjectStatus.label(
|
||||
project.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (project.description != null &&
|
||||
project.description!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(project.description!,
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppSectionHeader('Tasks (${tasks.length})'),
|
||||
if (tasks.isEmpty)
|
||||
Text('No tasks in this project yet.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant))
|
||||
else
|
||||
for (final t in tasks) _ProjectTaskTile(task: t),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectTaskTile extends StatelessWidget {
|
||||
const _ProjectTaskTile({required this.task});
|
||||
|
||||
final ProgrammerTask task;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: M3Card.outlined(
|
||||
onTap: () => context.go('/programmer-tasks/${task.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(task.title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatusPill.kind(
|
||||
programmerTaskStatusKind(task.status),
|
||||
label: ProgrammerTaskStatus.label(task.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/programmer_project.model.dart';
|
||||
import '../../providers/programmer_projects_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/reconnect_overlay.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
|
||||
class ProgrammerProjectsListScreen extends ConsumerWidget {
|
||||
const ProgrammerProjectsListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final projectsAsync = ref.watch(programmerProjectsProvider);
|
||||
final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? const [];
|
||||
final taskCountByProject = <String, int>{};
|
||||
for (final t in tasks) {
|
||||
if (t.projectId != null) {
|
||||
taskCountByProject[t.projectId!] =
|
||||
(taskCountByProject[t.projectId!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const AppPageHeader(
|
||||
title: 'Projects',
|
||||
subtitle: 'Group programmer tasks by project',
|
||||
),
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (projectsAsync.hasError && !projectsAsync.hasValue) {
|
||||
return AppErrorView(
|
||||
error: projectsAsync.error!,
|
||||
onRetry: () =>
|
||||
ref.invalidate(programmerProjectsProvider),
|
||||
);
|
||||
}
|
||||
if (!projectsAsync.hasValue && projectsAsync.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final projects = projectsAsync.valueOrNull ?? const [];
|
||||
if (projects.isEmpty) {
|
||||
return const AppEmptyView(
|
||||
icon: Icons.folder_outlined,
|
||||
title: 'No projects yet',
|
||||
subtitle: 'Create one with the + button, or add a '
|
||||
'project on the fly from a task.',
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: projects.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) {
|
||||
final p = projects[i];
|
||||
return _ProjectCard(
|
||||
project: p,
|
||||
taskCount: taskCountByProject[p.id] ?? 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: M3ExpandedFab(
|
||||
heroTag: 'create_programmer_project',
|
||||
onPressed: () => _showCreateDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Project'),
|
||||
),
|
||||
),
|
||||
const ReconnectIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreateDialog(BuildContext context, WidgetRef ref) async {
|
||||
final nameCtrl = TextEditingController();
|
||||
final descCtrl = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'New project',
|
||||
title: const Text('New Project'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Description (optional)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final name = nameCtrl.text.trim();
|
||||
final description = descCtrl.text.trim();
|
||||
nameCtrl.dispose();
|
||||
descCtrl.dispose();
|
||||
if (ok != true || name.isEmpty || !context.mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final id = await ref.read(programmerProjectsControllerProvider).createProject(
|
||||
name: name,
|
||||
description: description.isEmpty ? null : description,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
context.go('/programmer-projects/$id');
|
||||
} catch (e) {
|
||||
messenger
|
||||
.showSnackBar(SnackBar(content: Text('Could not create project: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectCard extends StatelessWidget {
|
||||
const _ProjectCard({required this.project, required this.taskCount});
|
||||
|
||||
final ProgrammerProject project;
|
||||
final int taskCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return M3Card.outlined(
|
||||
onTap: () => context.go('/programmer-projects/${project.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(project.name,
|
||||
style: tt.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatusPill.kind(
|
||||
project.isArchived ? StatusKind.neutral : StatusKind.success,
|
||||
label: ProgrammerProjectStatus.label(project.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (project.description != null &&
|
||||
project.description!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(project.description!,
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text('$taskCount task${taskCount == 1 ? '' : 's'}',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user