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 _actionProcessing = false;
bool _pauseActionInFlight = false;
Timer? _elapsedTicker;
DateTime _elapsedNow = AppTime.now();
late final AnimationController _saveAnimController;
late final Animation<double> _savePulse;
static const List<String> _statusOptions = [
@ -172,6 +174,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
_notedDebounce?.cancel();
_receivedDebounce?.cancel();
_actionDebounce?.cancel();
_elapsedTicker?.cancel();
_actionController?.dispose();
_actionFocusNode.dispose();
_actionScrollController.dispose();
@ -264,6 +267,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
ref.watch(taskActivityLogsProvider(task.id)).valueOrNull ??
<TaskActivityLog>[];
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<TaskDetailScreen>
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<TaskDetailScreen>
child: Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 68,
bottom: 92,
),
child: Column(
crossAxisAlignment:
@ -618,88 +629,103 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
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<TaskDetailScreen>
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(
Task task,
List<TaskActivityLog> logs,
@ -3319,6 +3399,17 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
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<TaskDetailScreen>
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<TaskDetailScreen>
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:',
),
),
],
),
),
),