Files
tasq/lib/screens/programmer_tasks/programmer_task_detail_screen.dart
redz1029 f8c3422715 refactor(programmer): shared task card driven by run states
Extract _TaskCard from the list screen into ProgrammerTaskCard, a
shared ConsumerWidget that reads programmerRunStatesProvider so the
paused-task pill and Start/Resume/Complete buttons are consistent
across any surface that embeds it.

Move the _complete body verbatim to completeProgrammerTaskWithConfirm
in programmer_task_actions.dart; the detail screen's _ActionBar now
calls the shared function.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 21:40:07 +08:00

439 lines
15 KiB
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_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/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'];
class ProgrammerTaskDetailScreen extends ConsumerWidget {
const ProgrammerTaskDetailScreen({super.key, required this.taskId});
final String taskId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final task = ref.watch(programmerTaskByIdProvider(taskId));
final logsAsync = ref.watch(programmerTaskActivityLogsProvider(taskId));
final userId = ref.watch(currentUserIdProvider);
final role = ref.watch(currentProfileProvider).valueOrNull?.role;
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
final profileById = {for (final p in profiles) p.id: p};
return Scaffold(
appBar: AppBar(
title: Text(task?.title ?? 'Programmer Task'),
actions: [
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
? const Center(child: CircularProgressIndicator())
: _DetailBody(
task: task,
logs: logsAsync.valueOrNull ?? const [],
profileById: profileById,
userId: userId,
role: role,
),
);
}
}
bool _canAct(ProgrammerTask task, String? userId, String? role) =>
task.assigneeId == userId || role == 'admin' || role == 'programmer';
class _DetailBody extends ConsumerWidget {
const _DetailBody({
required this.task,
required this.logs,
required this.profileById,
required this.userId,
required this.role,
});
final ProgrammerTask task;
final List<ProgrammerTaskActivityLog> logs;
final Map<String, Profile> profileById;
final String? userId;
final String? role;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
// Live running/paused state from the newest lifecycle event.
String latest = '';
for (final l in logs) {
if (_lifecycleActions.contains(l.actionType)) {
latest = l.actionType;
break;
}
}
final isPaused =
task.status == ProgrammerTaskStatus.inProgress && latest == 'paused';
final isRunning =
task.status == ProgrammerTaskStatus.inProgress && !isPaused;
final endAt = switch (task.status) {
ProgrammerTaskStatus.completed => task.completedAt ?? AppTime.now(),
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 DefaultTabController(
length: 3,
child: ResponsiveBody(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
M3Card.elevated(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
task.title,
style: tt.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
StatusPill.kind(
programmerTaskStatusKind(task.status),
label: isPaused
? 'Paused'
: ProgrammerTaskStatus.label(task.status),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
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,
children: [
Icon(Icons.person_outline,
size: 15, color: cs.onSurfaceVariant),
const SizedBox(width: 4),
Text(
assignee.fullName,
style: tt.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
],
),
if (task.description != null &&
task.description!.trim().isNotEmpty) ...[
const SizedBox(height: 14),
QuillReadOnly(task.description!, style: tt.bodyMedium),
],
const SizedBox(height: 14),
Row(
children: [
Icon(Icons.timer_outlined, size: 18, color: cs.primary),
const SizedBox(width: 8),
Text(
'Worked: ${formatDurationHm(worked)}',
style: tt.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
if (isRunning) ...[
const SizedBox(width: 8),
Text('· running',
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant)),
],
],
),
if (task.cancellationReason != null) ...[
const SizedBox(height: 10),
Text(
'Cancelled: ${task.cancellationReason}',
style: tt.bodySmall?.copyWith(color: cs.error),
),
],
],
),
),
),
if (ledger.total > Duration.zero) ...[
const SizedBox(height: 16),
TimeLedgerCard(ledger: ledger, profileById: profileById),
],
if (canAct && !task.isTerminal) ...[
const SizedBox(height: 16),
_ActionBar(
task: task,
isRunning: isRunning,
isPaused: isPaused,
),
],
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),
),
],
),
),
],
),
),
);
}
}
class _ActionBar extends ConsumerWidget {
const _ActionBar({
required this.task,
required this.isRunning,
required this.isPaused,
});
final ProgrammerTask task;
final bool isRunning;
final bool isPaused;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(programmerTasksControllerProvider);
final isQueued = task.status == ProgrammerTaskStatus.queued;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
if (isQueued)
FilledButton.icon(
onPressed: () =>
startProgrammerTaskWithPausePrompt(context, ref, task),
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Start'),
),
if (isPaused)
FilledButton.icon(
onPressed: () =>
startProgrammerTaskWithPausePrompt(context, ref, task),
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Resume'),
),
if (isRunning)
FilledButton.tonalIcon(
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
await controller.pauseTask(taskId: task.id);
messenger.showSnackBar(const SnackBar(content: Text('Paused')));
},
icon: const Icon(Icons.pause_rounded),
label: const Text('Pause'),
),
if (isRunning || isPaused)
OutlinedButton.icon(
onPressed: () =>
completeProgrammerTaskWithConfirm(context, ref, task),
icon: const Icon(Icons.check_rounded),
label: const Text('Complete'),
),
OutlinedButton.icon(
onPressed: () => _cancel(context, ref, task),
icon: const Icon(Icons.close_rounded),
label: const Text('Cancel'),
),
],
);
}
}
Future<void> _cancel(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
) async {
final reasonCtrl = TextEditingController();
final reason = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
semanticLabel: 'Cancel task',
title: const Text('Cancel task?'),
content: TextField(
controller: reasonCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Reason',
hintText: 'Why is this being cancelled?',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Back'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(reasonCtrl.text.trim()),
child: const Text('Cancel Task'),
),
],
),
);
reasonCtrl.dispose();
if (reason == null || reason.isEmpty) return;
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
await ref
.read(programmerTasksControllerProvider)
.cancelTask(taskId: task.id, reason: reason);
messenger.showSnackBar(const SnackBar(content: Text('Task cancelled')));
}
Future<void> _reassign(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
List<Profile> profiles,
String? userId,
) async {
final eligible = profiles
.where((p) => p.role == 'programmer' || p.role == 'admin')
.toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
if (eligible.isEmpty) return;
var selected = task.assigneeId ?? userId;
final chosen = await showDialog<String>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
semanticLabel: 'Reassign task',
title: const Text('Reassign task'),
content: DropdownButtonFormField<String>(
initialValue: eligible.any((p) => p.id == selected) ? selected : null,
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(() => selected = v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(selected),
child: const Text('Reassign'),
),
],
),
),
);
if (chosen == null || chosen == task.assigneeId) return;
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
await ref
.read(programmerTasksControllerProvider)
.reassign(taskId: task.id, newAssigneeId: chosen);
messenger.showSnackBar(const SnackBar(content: Text('Task reassigned')));
}