diff --git a/lib/main.dart b/lib/main.dart index 9299803c..36f5887f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -142,8 +142,9 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { ?.toString(); if (taskNumber != null && taskNumber.isNotEmpty) { sb.write('tasknum:$taskNumber'); - } else if (taskId != null && taskId.isNotEmpty) + } else if (taskId != null && taskId.isNotEmpty) { sb.write('task:$taskId'); + } if (ticketId != null && ticketId.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); sb.write('ticket:$ticketId'); @@ -320,10 +321,11 @@ Future main() async { String? name; if (res['full_name'] != null) { name = res['full_name'].toString(); - } else if (res['display_name'] != null) + } else if (res['display_name'] != null) { name = res['display_name'].toString(); - else if (res['name'] != null) + } else if (res['name'] != null) { name = res['name'].toString(); + } if (name != null && name.isNotEmpty) { dataForFormatting['actor_name'] = name; } @@ -362,8 +364,9 @@ Future main() async { ?.toString(); if (taskNumber != null && taskNumber.isNotEmpty) { sb.write('tasknum:$taskNumber'); - } else if (taskId != null && taskId.isNotEmpty) + } else if (taskId != null && taskId.isNotEmpty) { sb.write('task:$taskId'); + } if (ticketId != null && ticketId.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); sb.write('ticket:$ticketId'); diff --git a/lib/models/task.dart b/lib/models/task.dart index e6c9be4d..c4e9f3ae 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -25,6 +25,8 @@ class Task { this.requestTypeOther, this.requestCategory, this.actionTaken, + this.cancellationReason, + this.cancelledAt, }); final String id; @@ -53,6 +55,10 @@ class Task { // JSON serialized rich text for action taken (Quill Delta JSON encoded) final String? actionTaken; + /// Cancellation details when a task was cancelled. + final String? cancellationReason; + final DateTime? cancelledAt; + /// Helper that indicates whether a completed task still has missing /// metadata such as signatories or action details. The parameter is used /// by UI to surface a warning icon/banner when a task has been closed but @@ -91,6 +97,10 @@ class Task { requestedBy: map['requested_by'] as String?, notedBy: map['noted_by'] as String?, receivedBy: map['received_by'] as String?, + cancellationReason: map['cancellation_reason'] as String?, + cancelledAt: map['cancelled_at'] == null + ? null + : AppTime.parse(map['cancelled_at'] as String), actionTaken: (() { final at = map['action_taken']; if (at == null) return null; diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index e20b4012..083901d6 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -210,8 +210,10 @@ final tasksProvider = StreamProvider>((ref) { return 1; case 'completed': return 2; - default: + case 'cancelled': return 3; + default: + return 4; } } @@ -700,7 +702,13 @@ class TasksController { Future updateTaskStatus({ required String taskId, required String status, + String? reason, }) async { + if (status == 'cancelled') { + if (reason == null || reason.trim().isEmpty) { + throw Exception('Cancellation requires a reason.'); + } + } if (status == 'completed') { // fetch current metadata to validate several required fields try { @@ -751,7 +759,23 @@ class TasksController { } } - await _client.from('tasks').update({'status': status}).eq('id', taskId); + // persist status and cancellation reason (when provided) + final payload = {'status': status}; + if (status == 'cancelled') { + payload['cancellation_reason'] = reason; + } + await _client.from('tasks').update(payload).eq('id', taskId); + + // if cancelled, also set cancelled_at timestamp + if (status == 'cancelled') { + try { + final cancelledAt = AppTime.now().toIso8601String(); + await _client + .from('tasks') + .update({'cancelled_at': cancelledAt}) + .eq('id', taskId); + } catch (_) {} + } // Log important status transitions try { @@ -768,6 +792,13 @@ class TasksController { 'actor_id': actorId, 'action_type': 'completed', }); + } else if (status == 'cancelled') { + await _insertActivityRows(_client, { + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'cancelled', + 'meta': {'reason': reason}, + }); } } catch (_) { // ignore logging failures @@ -823,7 +854,66 @@ class TasksController { if (payload.isEmpty) { return; } + await _client.from('tasks').update(payload).eq('id', taskId); + + // Record activity logs for any metadata/signatory fields that were changed + try { + final actorId = _client.auth.currentUser?.id; + final List> logRows = []; + if (requestType != null) { + logRows.add({ + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'filled_request_type', + 'meta': {'value': requestType}, + }); + } + if (requestCategory != null) { + logRows.add({ + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'filled_request_category', + 'meta': {'value': requestCategory}, + }); + } + if (requestedBy != null) { + logRows.add({ + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'filled_requested_by', + 'meta': {'value': requestedBy}, + }); + } + if (notedBy != null) { + logRows.add({ + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'filled_noted_by', + 'meta': {'value': notedBy}, + }); + } + if (receivedBy != null) { + logRows.add({ + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'filled_received_by', + 'meta': {'value': receivedBy}, + }); + } + if (actionTaken != null) { + logRows.add({ + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'filled_action_taken', + 'meta': {'value': actionTaken}, + }); + } + + if (logRows.isNotEmpty) { + await _insertActivityRows(_client, logRows); + } + } catch (_) {} } /// Update editable task fields such as title, description, office or linked ticket. @@ -1230,10 +1320,11 @@ class TaskAssignmentsController { if (p != null) { if (p['full_name'] != null) { actorName = p['full_name'].toString(); - } else if (p['display_name'] != null) + } else if (p['display_name'] != null) { actorName = p['display_name'].toString(); - else if (p['name'] != null) + } else if (p['name'] != null) { actorName = p['name'].toString(); + } } } catch (_) {} } diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index ff179145..4e1c7d37 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -488,10 +488,7 @@ class _TaskDetailScreenState extends ConsumerState canAssign: showAssign, ), const SizedBox(height: 12), - Align( - alignment: Alignment.bottomRight, - child: _buildTatSection(task), - ), + const SizedBox.shrink(), ], ), ), @@ -2259,12 +2256,35 @@ class _TaskDetailScreenState extends ConsumerState ); break; case 'started': - timeline.add(_activityRow('Task started', actorName, l.createdAt)); + { + var label = 'Task started'; + if (latestAssignment != null && + l.actorId == latestAssignment.userId && + responseDuration != null) { + final assigneeName = + profileById[latestAssignment.userId]?.fullName ?? + latestAssignment.userId; + final resp = responseAt ?? AppTime.now(); + label = + 'Task started — Response: ${_formatDuration(responseDuration)} ($assigneeName responded at ${AppTime.formatDate(resp)} ${AppTime.formatTime(resp)})'; + } + timeline.add(_activityRow(label, actorName, l.createdAt)); + } break; case 'completed': - timeline.add( - _activityRow('Task completed', actorName, l.createdAt), - ); + { + var label = 'Task completed'; + if (task.startedAt != null) { + final start = task.startedAt!; + final end = task.completedAt ?? l.createdAt; + final exec = end.difference(start); + if (exec.inMilliseconds > 0) { + label = + 'Task completed — Execution: ${_formatDuration(exec)} (${AppTime.formatDate(start)} ${AppTime.formatTime(start)} → ${AppTime.formatDate(end)} ${AppTime.formatTime(end)})'; + } + } + timeline.add(_activityRow(label, actorName, l.createdAt)); + } break; default: timeline.add(_activityRow(l.actionType, actorName, l.createdAt)); @@ -2272,26 +2292,8 @@ class _TaskDetailScreenState extends ConsumerState } } - if (responseDuration != null) { - final assigneeName = - profileById[latestAssignment!.userId]?.fullName ?? - latestAssignment.userId; - timeline.add(const SizedBox(height: 12)); - timeline.add( - Row( - children: [ - const Icon(Icons.timer, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Response Time: ${_formatDuration(responseDuration)} ($assigneeName responded at ${responseAt!.toLocal().toString()})', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ); - } + // Response and execution times are now merged into the related + // 'Task started' and 'Task completed' timeline entries above. return SingleChildScrollView( child: Column( @@ -2301,46 +2303,7 @@ class _TaskDetailScreenState extends ConsumerState ); } - Widget _buildTatSection(Task task) { - final animateQueue = task.status == 'queued'; - final animateExecution = task.startedAt != null && task.completedAt == null; - - if (!animateQueue && !animateExecution) { - return _buildTatContent(task, AppTime.now()); - } - - return StreamBuilder( - stream: Stream.periodic( - const Duration(seconds: 1), - (tick) => tick, - ).asBroadcastStream(), - builder: (context, snapshot) { - return _buildTatContent(task, AppTime.now()); - }, - ); - } - - Widget _buildTatContent(Task task, DateTime now) { - final queueDuration = task.status == 'queued' - ? now.difference(task.createdAt) - : _safeDuration(task.startedAt?.difference(task.createdAt)); - final executionDuration = task.status == 'queued' - ? null - : task.startedAt == null - ? null - : task.completedAt == null - ? now.difference(task.startedAt!) - : task.completedAt!.difference(task.startedAt!); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Queue duration: ${_formatDuration(queueDuration)}'), - const SizedBox(height: 8), - Text('Task execution time: ${_formatDuration(executionDuration)}'), - ], - ); - } + // TAT helpers removed; timings are shown inline in the activity timeline. Widget _activityRow(String title, String actor, DateTime at) { return Padding( @@ -2361,7 +2324,7 @@ class _TaskDetailScreenState extends ConsumerState Text(title, style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 4), Text( - '$actor • ${at.toLocal()}', + '$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}', style: Theme.of(context).textTheme.labelSmall, ), ], @@ -2372,13 +2335,6 @@ class _TaskDetailScreenState extends ConsumerState ); } - Duration? _safeDuration(Duration? duration) { - if (duration == null) { - return null; - } - return duration.isNegative ? Duration.zero : duration; - } - String _formatDuration(Duration? duration) { if (duration == null) { return 'Pending';