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 []; 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 logs; final Map 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 _cancel( BuildContext context, WidgetRef ref, ProgrammerTask task, ) async { final reasonCtrl = TextEditingController(); final reason = await showDialog( 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 _reassign( BuildContext context, WidgetRef ref, ProgrammerTask task, List 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( context: context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( semanticLabel: 'Reassign task', title: const Text('Reassign task'), content: DropdownButtonFormField( 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'))); }