From 94088a8796a17b0fc4193290ff8dbd3bdc7a528a Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Wed, 4 Mar 2026 07:12:00 +0800 Subject: [PATCH] Implemented Task Pause and Resume --- lib/providers/tasks_provider.dart | 92 +++++++ lib/screens/tasks/task_detail_screen.dart | 244 ++++++++++++++++-- ...103000_add_pause_resume_activity_index.sql | 8 + 3 files changed, 327 insertions(+), 17 deletions(-) create mode 100644 supabase/migrations/20260304103000_add_pause_resume_activity_index.sql diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 0e44fca0..9a6e9dc6 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -54,6 +54,12 @@ String _uiFingerprint(TaskActivityLog log) { return '${log.taskId}|${log.actionType}'; } + // Pause/resume events are sequence-sensitive for execution-time calculations. + // Keep each row distinct to avoid collapsing multiple toggles in the same minute. + if (log.actionType == 'paused' || log.actionType == 'resumed') { + return '${log.taskId}|${log.id}'; + } + // The UI displays time as "MMM dd, yyyy hh:mm AA". Grouping by year, month, // day, hour, and minute ensures we collapse duplicates that look identical on screen. final visualMinute = @@ -610,6 +616,92 @@ class TasksController { // SupabaseClient instance. final dynamic _client; + Future _isTaskCurrentlyPaused(String taskId) async { + try { + final rows = await _client + .from('task_activity_logs') + .select('action_type, created_at') + .eq('task_id', taskId) + .inFilter('action_type', [ + 'started', + 'paused', + 'resumed', + 'completed', + 'cancelled', + ]) + .order('created_at', ascending: false) + .limit(1); + if (rows is List && rows.isNotEmpty) { + final latest = rows.first; + final action = latest['action_type']?.toString() ?? ''; + return action == 'paused'; + } + } catch (_) {} + return false; + } + + Future pauseTask({required String taskId}) async { + final row = await _client + .from('tasks') + .select('status') + .eq('id', taskId) + .maybeSingle(); + if (row is! Map) { + throw Exception('Task not found'); + } + + final status = (row['status'] as String?)?.trim() ?? ''; + if (status == 'completed' || status == 'cancelled' || status == 'closed') { + throw Exception('Cannot pause a terminal task.'); + } + if (status != 'in_progress') { + throw Exception('Only in-progress tasks can be paused.'); + } + + final alreadyPaused = await _isTaskCurrentlyPaused(taskId); + if (alreadyPaused) { + return; + } + + final actorId = _client.auth.currentUser?.id; + await _insertActivityRows(_client, { + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'paused', + }); + } + + Future resumeTask({required String taskId}) async { + final row = await _client + .from('tasks') + .select('status') + .eq('id', taskId) + .maybeSingle(); + if (row is! Map) { + throw Exception('Task not found'); + } + + final status = (row['status'] as String?)?.trim() ?? ''; + if (status == 'completed' || status == 'cancelled' || status == 'closed') { + throw Exception('Cannot resume a terminal task.'); + } + if (status != 'in_progress') { + throw Exception('Only in-progress tasks can be resumed.'); + } + + final isPaused = await _isTaskCurrentlyPaused(taskId); + if (!isPaused) { + return; + } + + final actorId = _client.auth.currentUser?.id; + await _insertActivityRows(_client, { + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'resumed', + }); + } + Future createTask({ required String title, required String description, diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 19d2c937..758c04e2 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -123,6 +123,7 @@ class _TaskDetailScreenState extends ConsumerState bool _actionSaving = false; bool _actionSaved = false; bool _actionProcessing = false; + bool _pauseActionInFlight = false; late final AnimationController _saveAnimController; late final Animation _savePulse; static const List _statusOptions = [ @@ -259,6 +260,10 @@ class _TaskDetailScreenState extends ConsumerState assignments, task.id, ); + final taskLogs = + ref.watch(taskActivityLogsProvider(task.id)).valueOrNull ?? + []; + final isTaskPaused = _isTaskCurrentlyPaused(task, taskLogs); final typingState = ref.watch(typingIndicatorProvider(typingChannelId)); final canSendMessages = task.status != 'completed'; @@ -584,22 +589,120 @@ class _TaskDetailScreenState extends ConsumerState child: TabBarView( children: [ // Assignees (Tab 1) - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - TaskAssignmentSection( - taskId: task.id, - canAssign: showAssign, + Stack( + children: [ + Positioned.fill( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only( + top: 8.0, + bottom: 68, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + TaskAssignmentSection( + taskId: task.id, + canAssign: showAssign, + ), + const SizedBox(height: 12), + const SizedBox.shrink(), + ], + ), ), - const SizedBox(height: 12), - const SizedBox.shrink(), - ], + ), ), - ), + if (canUpdateStatus && + task.status == 'in_progress') + Positioned( + right: 8, + bottom: 8, + child: IconButton.filledTonal( + tooltip: isTaskPaused + ? 'Resume task' + : 'Pause task', + onPressed: _pauseActionInFlight + ? null + : () async { + setState( + () => _pauseActionInFlight = + true, + ); + try { + if (isTaskPaused) { + await ref + .read( + tasksControllerProvider, + ) + .resumeTask( + taskId: task.id, + ); + ref.invalidate( + taskActivityLogsProvider( + task.id, + ), + ); + if (mounted) { + showSuccessSnackBar( + context, + 'Task resumed', + ); + } + } else { + await ref + .read( + tasksControllerProvider, + ) + .pauseTask( + taskId: task.id, + ); + ref.invalidate( + taskActivityLogsProvider( + task.id, + ), + ); + if (mounted) { + showInfoSnackBar( + context, + 'Task paused', + ); + } + } + } catch (e) { + if (mounted) { + showErrorSnackBar( + context, + e.toString(), + ); + } + } finally { + if (mounted) { + setState( + () => + _pauseActionInFlight = + false, + ); + } + } + }, + icon: _pauseActionInFlight + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + isTaskPaused + ? Icons.play_arrow + : Icons.pause, + ), + ), + ), + ], ), // Type & Category @@ -2967,13 +3070,37 @@ class _TaskDetailScreenState extends ConsumerState ); } break; + case 'paused': + timeline.add( + _activityRow( + 'Task paused', + actorName, + l.createdAt, + icon: Icons.pause_circle, + ), + ); + break; + case 'resumed': + timeline.add( + _activityRow( + 'Task resumed', + actorName, + l.createdAt, + icon: Icons.play_circle, + ), + ); + break; case 'completed': { var label = 'Task completed'; - if (task.startedAt != null) { - final start = task.startedAt!; + final start = _resolveExecutionStart(task, logs); + if (start != null) { final end = task.completedAt ?? l.createdAt; - final exec = end.difference(start); + final exec = _computeEffectiveExecutionDuration( + task, + logs, + end, + ); if (exec.inMilliseconds > 0) { label = 'Task completed — Execution: ${_formatDuration(exec)} (${AppTime.formatDate(start)} ${AppTime.formatTime(start)} → ${AppTime.formatDate(end)} ${AppTime.formatTime(end)})'; @@ -3094,6 +3221,89 @@ class _TaskDetailScreenState extends ConsumerState ); } + DateTime? _resolveExecutionStart(Task task, List logs) { + DateTime? started; + for (final entry in logs.reversed) { + if (entry.actionType == 'started') { + started = entry.createdAt; + break; + } + } + return started ?? task.startedAt; + } + + Duration _computeEffectiveExecutionDuration( + Task task, + List logs, + DateTime endAt, + ) { + final start = _resolveExecutionStart(task, logs); + if (start == null || !endAt.isAfter(start)) { + return Duration.zero; + } + + Duration pausedTotal = Duration.zero; + DateTime? pausedSince; + final events = logs.reversed.where((entry) { + if (entry.createdAt.isBefore(start)) return false; + if (entry.createdAt.isAfter(endAt)) return false; + return entry.actionType == 'paused' || entry.actionType == 'resumed'; + }); + + for (final event in events) { + if (event.actionType == 'paused') { + pausedSince ??= event.createdAt; + } else if (event.actionType == 'resumed' && pausedSince != null) { + if (event.createdAt.isAfter(pausedSince)) { + pausedTotal += event.createdAt.difference(pausedSince); + } + pausedSince = null; + } + } + + if (pausedSince != null && endAt.isAfter(pausedSince)) { + pausedTotal += endAt.difference(pausedSince); + } + + final total = endAt.difference(start) - pausedTotal; + if (total.isNegative) { + return Duration.zero; + } + return total; + } + + bool _isTaskCurrentlyPaused(Task task, List logs) { + if (task.status != 'in_progress') { + return false; + } + + var started = task.startedAt != null; + var paused = false; + for (final entry in logs.reversed) { + switch (entry.actionType) { + case 'started': + started = true; + paused = false; + break; + case 'paused': + if (started) { + paused = true; + } + break; + case 'resumed': + if (started) { + paused = false; + } + break; + case 'completed': + case 'cancelled': + paused = false; + break; + } + } + return started && paused; + } + String _formatDuration(Duration? duration) { if (duration == null) { return 'Pending'; diff --git a/supabase/migrations/20260304103000_add_pause_resume_activity_index.sql b/supabase/migrations/20260304103000_add_pause_resume_activity_index.sql new file mode 100644 index 00000000..3336ff26 --- /dev/null +++ b/supabase/migrations/20260304103000_add_pause_resume_activity_index.sql @@ -0,0 +1,8 @@ +-- Support pause/resume task execution tracking queries. +-- +-- The app records `paused` and `resumed` events in `task_activity_logs` +-- while task status remains `in_progress`. This index speeds up lookups +-- that resolve current pause state and execution-time calculations. + +create index if not exists idx_task_activity_logs_task_action_created_at + on public.task_activity_logs(task_id, action_type, created_at desc);