diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 758c04e2..d70d832b 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -124,6 +124,8 @@ class _TaskDetailScreenState extends ConsumerState bool _actionSaved = false; bool _actionProcessing = false; bool _pauseActionInFlight = false; + Timer? _elapsedTicker; + DateTime _elapsedNow = AppTime.now(); late final AnimationController _saveAnimController; late final Animation _savePulse; static const List _statusOptions = [ @@ -172,6 +174,7 @@ class _TaskDetailScreenState extends ConsumerState _notedDebounce?.cancel(); _receivedDebounce?.cancel(); _actionDebounce?.cancel(); + _elapsedTicker?.cancel(); _actionController?.dispose(); _actionFocusNode.dispose(); _actionScrollController.dispose(); @@ -264,6 +267,11 @@ class _TaskDetailScreenState extends ConsumerState ref.watch(taskActivityLogsProvider(task.id)).valueOrNull ?? []; final isTaskPaused = _isTaskCurrentlyPaused(task, taskLogs); + final elapsedDuration = _currentElapsedDuration( + task, + taskLogs, + isTaskPaused, + ); final typingState = ref.watch(typingIndicatorProvider(typingChannelId)); final canSendMessages = task.status != 'completed'; @@ -272,7 +280,10 @@ class _TaskDetailScreenState extends ConsumerState ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)), ); - WidgetsBinding.instance.addPostFrameCallback((_) => _updateSaveAnim()); + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateSaveAnim(); + _syncElapsedTicker(task, taskLogs, isTaskPaused); + }); final realtime = ref.watch(realtimeControllerProvider); final isRetrieving = @@ -596,7 +607,7 @@ class _TaskDetailScreenState extends ConsumerState child: Padding( padding: const EdgeInsets.only( top: 8.0, - bottom: 68, + bottom: 92, ), child: Column( crossAxisAlignment: @@ -618,88 +629,103 @@ class _TaskDetailScreenState extends ConsumerState 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) { + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + IconButton.filledTonal( + tooltip: isTaskPaused + ? 'Resume task' + : 'Pause task', + onPressed: _pauseActionInFlight + ? null + : () async { setState( () => _pauseActionInFlight = - false, + true, ); - } - } - }, - icon: _pauseActionInFlight - ? const SizedBox( - width: 18, - height: 18, - child: - CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : Icon( - isTaskPaused - ? Icons.play_arrow - : Icons.pause, - ), + 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, + ), + ), + const SizedBox(height: 4), + Text( + 'Elapsed ${_formatDurationClock(elapsedDuration)}', + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + ], ), ), ], @@ -3232,6 +3258,60 @@ class _TaskDetailScreenState extends ConsumerState return started ?? task.startedAt; } + DateTime? _latestPauseSince(List logs, DateTime notBefore) { + for (final entry in logs) { + if (entry.actionType != 'paused') continue; + if (entry.createdAt.isBefore(notBefore)) continue; + return entry.createdAt; + } + return null; + } + + Duration? _currentElapsedDuration( + Task task, + List logs, + bool isPaused, + ) { + if (task.status != 'in_progress') { + return null; + } + final start = _resolveExecutionStart(task, logs); + if (start == null) { + return null; + } + + final pausedAt = isPaused ? _latestPauseSince(logs, start) : null; + final endAt = pausedAt ?? _elapsedNow; + return _computeEffectiveExecutionDuration(task, logs, endAt); + } + + void _syncElapsedTicker( + Task task, + List logs, + bool isPaused, + ) { + final shouldRun = + task.status == 'in_progress' && + !isPaused && + _resolveExecutionStart(task, logs) != null; + + if (shouldRun) { + if (_elapsedTicker?.isActive != true) { + _elapsedNow = AppTime.now(); + _elapsedTicker = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() { + _elapsedNow = AppTime.now(); + }); + }); + } + } else { + _elapsedTicker?.cancel(); + _elapsedTicker = null; + _elapsedNow = AppTime.now(); + } + } + Duration _computeEffectiveExecutionDuration( Task task, List logs, @@ -3319,6 +3399,17 @@ class _TaskDetailScreenState extends ConsumerState return '${minutes}m'; } + String _formatDurationClock(Duration? duration) { + if (duration == null || duration.isNegative) { + return '00:00:00'; + } + final totalSeconds = duration.inSeconds; + final hours = (totalSeconds ~/ 3600).toString().padLeft(2, '0'); + final minutes = ((totalSeconds % 3600) ~/ 60).toString().padLeft(2, '0'); + final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); + return '$hours:$minutes:$seconds'; + } + Widget _buildMentionText( String text, Color baseColor, @@ -4087,6 +4178,8 @@ class _TaskDetailScreenState extends ConsumerState context: context, builder: (dialogContext) { var isSaving = false; + var reasonProcessing = false; + var reasonDeepSeek = false; return StatefulBuilder( builder: (ctx, setState) { return AlertDialog( @@ -4097,13 +4190,45 @@ class _TaskDetailScreenState extends ConsumerState width: 360, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: TextField( - controller: reasonCtrl, - maxLines: 3, - decoration: const InputDecoration( - labelText: 'Reason', - hintText: 'Provide a justification for cancelling', - ), + child: Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: reasonCtrl, + enabled: !isSaving, + labelText: 'Reason', + maxLines: 3, + isProcessing: reasonProcessing, + useDeepSeekColors: reasonDeepSeek, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: reasonCtrl, + onTextUpdated: (updatedText) { + setState(() { + reasonCtrl.text = updatedText; + }); + }, + onProcessingStateChanged: (isProcessing) { + setState(() { + reasonProcessing = isProcessing; + }); + }, + onProviderChanged: (isDeepSeek) { + setState(() => reasonDeepSeek = isDeepSeek); + }, + tooltip: + 'Improve cancellation reason with Gemini', + promptBuilder: (_) => + 'Improve this task cancellation reason for ' + 'clarity, professionalism, and concise ' + 'English. Keep the original intent. Return ' + 'ONLY the improved reason, no explanations:', + ), + ), + ], ), ), ),