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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../widgets/quill_field.dart';
|
||||
|
||||
/// Edit a task's Title (plain) and Description (Quill). Saves via the existing
|
||||
/// [ProgrammerTasksController.updateTask].
|
||||
Future<void> showEditProgrammerTaskDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgrammerTask task,
|
||||
) async {
|
||||
final titleCtrl = TextEditingController(text: task.title);
|
||||
final descQuill = quillControllerFromDelta(task.description);
|
||||
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final dialogWidth =
|
||||
math.min(480.0, MediaQuery.of(context).size.width - 48);
|
||||
return AlertDialog(
|
||||
semanticLabel: 'Edit task',
|
||||
title: const Text('Edit 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'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Description',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
QuillFieldEditor(
|
||||
controller: descQuill,
|
||||
placeholder: 'Describe the task…',
|
||||
height: 180,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final title = titleCtrl.text.trim();
|
||||
titleCtrl.dispose();
|
||||
final description =
|
||||
quillPlainText(descQuill).isEmpty ? '' : quillToDeltaJson(descQuill);
|
||||
descQuill.dispose();
|
||||
if (ok != true || title.isEmpty || !context.mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(programmerTasksControllerProvider).updateTask(
|
||||
taskId: task.id,
|
||||
title: title,
|
||||
description: description,
|
||||
);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Task updated')));
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not update: $e')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
|
||||
/// After the ASSIGNEE saves a work log or comment on their own actively-running
|
||||
/// task, ask whether to pause the timer. No-op for anyone else, or when the task
|
||||
/// isn't the caller's currently-running one.
|
||||
Future<void> maybePromptPauseTimer(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgrammerTask task,
|
||||
) async {
|
||||
final userId = ref.read(currentUserIdProvider);
|
||||
if (task.assigneeId != userId) return;
|
||||
final controller = ref.read(programmerTasksControllerProvider);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// Fresh query — the running-task provider can lag right after a start/switch.
|
||||
final running = await controller.findRunningTaskForCurrentUser();
|
||||
if (running?.id != task.id) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final pause = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Pause the timer',
|
||||
title: const Text('Pause the timer?'),
|
||||
content: Text('You logged on "${task.title}". Pause its timer now?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Keep running'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Pause'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (pause == true) {
|
||||
await controller.pauseTask(taskId: task.id);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Timer paused')));
|
||||
}
|
||||
}
|
||||
|
||||
/// After a HELPER logs Time Consumed on a task they don't own, ask whether to
|
||||
/// deduct that time from their own currently-running task. No-op if they have no
|
||||
/// running (unpaused) task. [helpedTaskId] is recorded for audit.
|
||||
Future<void> maybePromptDeductHelperTime(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
int minutes,
|
||||
String helpedTaskId,
|
||||
) async {
|
||||
if (minutes <= 0) return;
|
||||
final controller = ref.read(programmerTasksControllerProvider);
|
||||
final running = await controller.findRunningTaskForCurrentUser();
|
||||
if (running == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final deduct = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Deduct helped time',
|
||||
title: const Text('Deduct this time?'),
|
||||
content: Text(
|
||||
'You logged $minutes min helping. Deduct it from your active task '
|
||||
'"${running.title}" so your time stays accurate?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Deduct'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (deduct == true) {
|
||||
await controller.addAdjustment(
|
||||
taskId: running.id,
|
||||
seconds: minutes * 60,
|
||||
reason: 'helped',
|
||||
sourceTaskId: helpedTaskId,
|
||||
);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Deducted $minutes min from "${running.title}"')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../models/profile.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 '../../theme/app_status_colors.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
@@ -17,6 +18,7 @@ import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../../widgets/tech_chip.dart';
|
||||
import 'programmer_task_actions.dart';
|
||||
import 'programmer_task_create.dart';
|
||||
import 'programmer_task_status_kind.dart';
|
||||
|
||||
class ProgrammerTasksListScreen extends ConsumerStatefulWidget {
|
||||
@@ -128,7 +130,7 @@ class _ProgrammerTasksListScreenState
|
||||
bottom: 16,
|
||||
child: M3ExpandedFab(
|
||||
heroTag: 'create_programmer_task',
|
||||
onPressed: () => _showCreateDialog(context),
|
||||
onPressed: () => showCreateProgrammerTaskDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Task'),
|
||||
),
|
||||
@@ -138,125 +140,6 @@ class _ProgrammerTasksListScreenState
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreateDialog(BuildContext context) 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 titleCtrl = TextEditingController();
|
||||
final descCtrl = TextEditingController();
|
||||
var category = ProgrammerTaskCategory.softwareDevelopment;
|
||||
var assigneeId = userId;
|
||||
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
semanticLabel: 'New programmer task',
|
||||
title: const Text('New Programmer Task'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
hintText: 'What are you working on?',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
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();
|
||||
descCtrl.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final title = titleCtrl.text.trim();
|
||||
titleCtrl.dispose();
|
||||
final description = descCtrl.text.trim();
|
||||
descCtrl.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.isEmpty ? null : description,
|
||||
assigneeId: assigneeId,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
context.go('/programmer-tasks/$id');
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not create task: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskCard extends ConsumerWidget {
|
||||
@@ -280,6 +163,10 @@ class _TaskCard extends ConsumerWidget {
|
||||
final canStart = isMine && task.status == ProgrammerTaskStatus.queued;
|
||||
final canResume = isPaused;
|
||||
|
||||
final project = task.projectId == null
|
||||
? null
|
||||
: ref.watch(programmerProjectByIdProvider(task.projectId!));
|
||||
|
||||
final pillKind =
|
||||
isPaused ? StatusKind.warning : programmerTaskStatusKind(task.status);
|
||||
final pillLabel =
|
||||
@@ -314,6 +201,9 @@ class _TaskCard extends ConsumerWidget {
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
TechChip(task.category, icon: Icons.category_outlined, dense: true),
|
||||
if (project != null)
|
||||
TechChip(project.name,
|
||||
icon: Icons.folder_outlined, dense: true),
|
||||
if (assignee != null && !isMine)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_task_activity_log.model.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
|
||||
/// The task's activity timeline (created/started/paused/adjusted/…), newest first.
|
||||
class ActivityTimeline extends StatelessWidget {
|
||||
const ActivityTimeline({
|
||||
super.key,
|
||||
required this.logs,
|
||||
required this.profileById,
|
||||
});
|
||||
|
||||
final List<ProgrammerTaskActivityLog> logs;
|
||||
final Map<String, Profile> profileById;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionHeader('Activity'),
|
||||
if (logs.isEmpty)
|
||||
Text('No activity yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
|
||||
else
|
||||
for (final log in logs)
|
||||
_ActivityRow(log: log, profileById: profileById),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 _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',
|
||||
'adjusted' || 'adjustment' => 'adjusted time',
|
||||
'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,
|
||||
'adjustment' => Icons.timelapse_outlined,
|
||||
'updated' => Icons.edit_outlined,
|
||||
_ => Icons.circle_outlined,
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../models/programmer_task_comment.model.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_task_comments_provider.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import '../../../widgets/m3_card.dart';
|
||||
import '../../../widgets/quill_field.dart';
|
||||
import '../programmer_task_prompts.dart';
|
||||
|
||||
const _imageExts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
|
||||
|
||||
bool _isImage(String name) =>
|
||||
_imageExts.contains(name.split('.').last.toLowerCase());
|
||||
|
||||
/// Comment thread + composer. Any user who can see the task may comment and
|
||||
/// attach files. The assignee is prompted to pause after commenting.
|
||||
class CommentSection extends ConsumerStatefulWidget {
|
||||
const CommentSection({super.key, required this.task});
|
||||
|
||||
final ProgrammerTask task;
|
||||
|
||||
@override
|
||||
ConsumerState<CommentSection> createState() => _CommentSectionState();
|
||||
}
|
||||
|
||||
class _CommentSectionState extends ConsumerState<CommentSection> {
|
||||
final _bodyQuill = quillControllerFromDelta(null);
|
||||
final List<CommentUpload> _pending = [];
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bodyQuill.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickFiles() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.any,
|
||||
withData: true,
|
||||
allowMultiple: true,
|
||||
);
|
||||
if (result == null) return;
|
||||
for (final f in result.files) {
|
||||
if (f.bytes == null) continue;
|
||||
if (f.bytes!.length > 25 * 1024 * 1024) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${f.name} exceeds 25MB — skipped')),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
_pending.add((name: f.name, bytes: f.bytes!));
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final hasText = quillPlainText(_bodyQuill).isNotEmpty;
|
||||
if ((!hasText && _pending.isEmpty) || _sending) return;
|
||||
setState(() => _sending = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(programmerTaskCommentsControllerProvider).addComment(
|
||||
taskId: widget.task.id,
|
||||
body: hasText ? quillToDeltaJson(_bodyQuill) : '',
|
||||
files: List.of(_pending),
|
||||
);
|
||||
_bodyQuill.clear();
|
||||
_pending.clear();
|
||||
if (!mounted) return;
|
||||
await maybePromptPauseTimer(context, ref, widget.task);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not send: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final commentsAsync =
|
||||
ref.watch(programmerTaskCommentsProvider(widget.task.id));
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
|
||||
final profileById = {for (final p in profiles) p.id: p};
|
||||
final comments = commentsAsync.valueOrNull ?? const [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionHeader('Comments'),
|
||||
// Composer
|
||||
QuillFieldEditor(
|
||||
controller: _bodyQuill,
|
||||
placeholder: 'Add a comment…',
|
||||
height: 110,
|
||||
),
|
||||
if (_pending.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < _pending.length; i++)
|
||||
InputChip(
|
||||
label: Text(_pending[i].name,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
onDeleted: () => setState(() => _pending.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Attach files',
|
||||
onPressed: _sending ? null : _pickFiles,
|
||||
icon: const Icon(Icons.attach_file),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _sending ? null : _send,
|
||||
icon: _sending
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send, size: 18),
|
||||
label: const Text('Send'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (comments.isEmpty)
|
||||
Text('No comments yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
|
||||
else
|
||||
for (final c in comments)
|
||||
_CommentTile(comment: c, author: profileById[c.authorId]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentTile extends ConsumerWidget {
|
||||
const _CommentTile({required this.comment, required this.author});
|
||||
|
||||
final ProgrammerTaskComment comment;
|
||||
final Profile? author;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final controller = ref.read(programmerTaskCommentsControllerProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: M3Card.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(author?.fullName ?? 'Someone',
|
||||
style: tt.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
Text(AppTime.relative(comment.createdAt),
|
||||
style:
|
||||
tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
if (comment.body.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
QuillReadOnly(comment.body, style: tt.bodyMedium),
|
||||
],
|
||||
if (comment.attachments.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final a in comment.attachments)
|
||||
_AttachmentChip(
|
||||
attachment: a,
|
||||
url: controller.attachmentUrl(a.path),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentChip extends StatelessWidget {
|
||||
const _AttachmentChip({required this.attachment, required this.url});
|
||||
|
||||
final ProgrammerCommentAttachment attachment;
|
||||
final String url;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isImage(attachment.name)) {
|
||||
return InkWell(
|
||||
onTap: () => _openImage(context),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: 84,
|
||||
height: 84,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _fileChip(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _fileChip(context);
|
||||
}
|
||||
|
||||
Widget _fileChip(BuildContext context) => ActionChip(
|
||||
avatar: const Icon(Icons.insert_drive_file_outlined, size: 16),
|
||||
label: Text(attachment.name, overflow: TextOverflow.ellipsis),
|
||||
onPressed: () => _showUrl(context),
|
||||
);
|
||||
|
||||
void _openImage(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InteractiveViewer(child: Image.network(url)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(attachment.name,
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showUrl(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Attachment link',
|
||||
title: Text(attachment.name),
|
||||
content: SelectableText(url),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import '../../../widgets/m3_card.dart';
|
||||
|
||||
/// Per-contributor time breakdown for a task: the assignee's running duration
|
||||
/// plus each helper's logged minutes, and the total.
|
||||
class TimeLedgerCard extends StatelessWidget {
|
||||
const TimeLedgerCard({
|
||||
super.key,
|
||||
required this.ledger,
|
||||
required this.profileById,
|
||||
});
|
||||
|
||||
final TaskTimeLedger ledger;
|
||||
final Map<String, Profile> profileById;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ledger.total == Duration.zero) return const SizedBox.shrink();
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return M3Card.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppSectionHeader(
|
||||
'Time ledger',
|
||||
trailing: Text(
|
||||
formatDurationHm(ledger.total),
|
||||
style: tt.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
for (final entry in ledger.all)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
entry.isAssignee
|
||||
? Icons.play_circle_outline
|
||||
: Icons.volunteer_activism_outlined,
|
||||
size: 16,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
profileById[entry.userId]?.fullName ?? 'Unknown',
|
||||
style: tt.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (entry.isAssignee)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text('assignee',
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant)),
|
||||
),
|
||||
Text(
|
||||
formatDurationHm(entry.duration),
|
||||
style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_task_work_logs_provider.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import '../../../widgets/quill_field.dart';
|
||||
import '../programmer_task_prompts.dart';
|
||||
|
||||
/// Work-log list + composer. The assignee logs progress notes (no time field);
|
||||
/// a non-assignee helper gets Hours + Minutes fields and is prompted to deduct
|
||||
/// that time from their own running task. The assignee is prompted to pause
|
||||
/// after logging.
|
||||
class WorkLogSection extends ConsumerStatefulWidget {
|
||||
const WorkLogSection({super.key, required this.task, required this.isAssignee});
|
||||
|
||||
final ProgrammerTask task;
|
||||
final bool isAssignee;
|
||||
|
||||
@override
|
||||
ConsumerState<WorkLogSection> createState() => _WorkLogSectionState();
|
||||
}
|
||||
|
||||
class _WorkLogSectionState extends ConsumerState<WorkLogSection> {
|
||||
final _descQuill = quillControllerFromDelta(null);
|
||||
final _hoursCtrl = TextEditingController();
|
||||
final _minutesCtrl = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descQuill.dispose();
|
||||
_hoursCtrl.dispose();
|
||||
_minutesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_saving) return;
|
||||
if (quillPlainText(_descQuill).isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Describe what you worked on')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
int? minutes;
|
||||
if (!widget.isAssignee) {
|
||||
final h = int.tryParse(_hoursCtrl.text.trim()) ?? 0;
|
||||
final m = int.tryParse(_minutesCtrl.text.trim()) ?? 0;
|
||||
minutes = h * 60 + m;
|
||||
if (minutes <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enter the time you spent helping')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(programmerTaskWorkLogsControllerProvider).addWorkLog(
|
||||
taskId: widget.task.id,
|
||||
description: quillToDeltaJson(_descQuill),
|
||||
minutes: minutes,
|
||||
);
|
||||
_descQuill.clear();
|
||||
_hoursCtrl.clear();
|
||||
_minutesCtrl.clear();
|
||||
if (!mounted) return;
|
||||
if (widget.isAssignee) {
|
||||
await maybePromptPauseTimer(context, ref, widget.task);
|
||||
} else if (minutes != null) {
|
||||
await maybePromptDeductHelperTime(context, ref, minutes, widget.task.id);
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not save: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final logsAsync = ref.watch(programmerTaskWorkLogsProvider(widget.task.id));
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
|
||||
final profileById = {for (final p in profiles) p.id: p};
|
||||
final logs = logsAsync.valueOrNull ?? const [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionHeader('Work Log'),
|
||||
QuillFieldEditor(
|
||||
controller: _descQuill,
|
||||
placeholder: widget.isAssignee
|
||||
? 'What did you work on?'
|
||||
: 'What did you help with?',
|
||||
height: 120,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (!widget.isAssignee) ...[
|
||||
SizedBox(
|
||||
width: 78,
|
||||
child: TextField(
|
||||
controller: _hoursCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Hours', isDense: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: TextField(
|
||||
controller: _minutesCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Minutes', isDense: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _saving ? null : _submit,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Log'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (logs.isEmpty)
|
||||
Text('No work logged yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
|
||||
else
|
||||
for (final log in logs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
profileById[log.authorId]?.fullName ?? 'Someone',
|
||||
style: tt.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (log.minutes != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
formatDurationHm(Duration(minutes: log.minutes!)),
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.primary,
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Text(AppTime.relative(log.createdAt),
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
QuillReadOnly(log.description, style: tt.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user