Task execution Elapsed timer

This commit is contained in:
Marc Rejohn Castillano 2026-03-04 18:42:37 +08:00
parent 94088a8796
commit 82fe619f22

View File

@ -124,6 +124,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
bool _actionSaved = false; bool _actionSaved = false;
bool _actionProcessing = false; bool _actionProcessing = false;
bool _pauseActionInFlight = false; bool _pauseActionInFlight = false;
Timer? _elapsedTicker;
DateTime _elapsedNow = AppTime.now();
late final AnimationController _saveAnimController; late final AnimationController _saveAnimController;
late final Animation<double> _savePulse; late final Animation<double> _savePulse;
static const List<String> _statusOptions = [ static const List<String> _statusOptions = [
@ -172,6 +174,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
_notedDebounce?.cancel(); _notedDebounce?.cancel();
_receivedDebounce?.cancel(); _receivedDebounce?.cancel();
_actionDebounce?.cancel(); _actionDebounce?.cancel();
_elapsedTicker?.cancel();
_actionController?.dispose(); _actionController?.dispose();
_actionFocusNode.dispose(); _actionFocusNode.dispose();
_actionScrollController.dispose(); _actionScrollController.dispose();
@ -264,6 +267,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
ref.watch(taskActivityLogsProvider(task.id)).valueOrNull ?? ref.watch(taskActivityLogsProvider(task.id)).valueOrNull ??
<TaskActivityLog>[]; <TaskActivityLog>[];
final isTaskPaused = _isTaskCurrentlyPaused(task, taskLogs); final isTaskPaused = _isTaskCurrentlyPaused(task, taskLogs);
final elapsedDuration = _currentElapsedDuration(
task,
taskLogs,
isTaskPaused,
);
final typingState = ref.watch(typingIndicatorProvider(typingChannelId)); final typingState = ref.watch(typingIndicatorProvider(typingChannelId));
final canSendMessages = task.status != 'completed'; final canSendMessages = task.status != 'completed';
@ -272,7 +280,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)), 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 realtime = ref.watch(realtimeControllerProvider);
final isRetrieving = final isRetrieving =
@ -596,7 +607,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 8.0, top: 8.0,
bottom: 68, bottom: 92,
), ),
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment:
@ -618,88 +629,103 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Positioned( Positioned(
right: 8, right: 8,
bottom: 8, bottom: 8,
child: IconButton.filledTonal( child: Column(
tooltip: isTaskPaused mainAxisSize: MainAxisSize.min,
? 'Resume task' crossAxisAlignment:
: 'Pause task', CrossAxisAlignment.end,
onPressed: _pauseActionInFlight children: [
? null IconButton.filledTonal(
: () async { tooltip: isTaskPaused
setState( ? 'Resume task'
() => _pauseActionInFlight = : 'Pause task',
true, onPressed: _pauseActionInFlight
); ? null
try { : () async {
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( setState(
() => () =>
_pauseActionInFlight = _pauseActionInFlight =
false, true,
); );
} try {
} if (isTaskPaused) {
}, await ref
icon: _pauseActionInFlight .read(
? const SizedBox( tasksControllerProvider,
width: 18, )
height: 18, .resumeTask(
child: taskId: task.id,
CircularProgressIndicator( );
strokeWidth: 2, ref.invalidate(
), taskActivityLogsProvider(
) task.id,
: Icon( ),
isTaskPaused );
? Icons.play_arrow if (mounted) {
: Icons.pause, 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<TaskDetailScreen>
return started ?? task.startedAt; return started ?? task.startedAt;
} }
DateTime? _latestPauseSince(List<TaskActivityLog> 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<TaskActivityLog> 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<TaskActivityLog> 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( Duration _computeEffectiveExecutionDuration(
Task task, Task task,
List<TaskActivityLog> logs, List<TaskActivityLog> logs,
@ -3319,6 +3399,17 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
return '${minutes}m'; 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( Widget _buildMentionText(
String text, String text,
Color baseColor, Color baseColor,
@ -4087,6 +4178,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
var isSaving = false; var isSaving = false;
var reasonProcessing = false;
var reasonDeepSeek = false;
return StatefulBuilder( return StatefulBuilder(
builder: (ctx, setState) { builder: (ctx, setState) {
return AlertDialog( return AlertDialog(
@ -4097,13 +4190,45 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
width: 360, width: 360,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
child: TextField( child: Row(
controller: reasonCtrl, children: [
maxLines: 3, Expanded(
decoration: const InputDecoration( child: GeminiAnimatedTextField(
labelText: 'Reason', controller: reasonCtrl,
hintText: 'Provide a justification for cancelling', 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:',
),
),
],
), ),
), ),
), ),