feat: cron job editor dialog (closes #150)

Implements a full-screen cron job editor dialog following the skills editor pattern:
- Create/edit/delete cron jobs with all backend fields
- CronJobRepeat data class matching backend object shape
- Fixed Gson crash on cron tab load
This commit is contained in:
M57 authored and GitHub committed 2026-06-29 18:48:45 +03:00
1 parent 4849f4c1c6
commit 4c075be026
6 files changed
+608 -10

No files matched your search

@@ -1,5 +1,10 @@
package com.m57.hermescontrol.data.model
data class CronJobRepeat(
val times: Int? = null,
val completed: Int = 0,
)
data class CronJob(
val id: String,
val name: String,
@@ -10,6 +15,20 @@ data class CronJob(
val schedule_display: String? = null,
val last_status: String? = null,
val next_run_at: String? = null,
// Full editor fields — all optional with defaults for backward compat
val enabled: Boolean? = null,
val prompt: String? = null,
val deliver: String? = null,
val skills: List<String>? = null,
val model: String? = null,
val provider: String? = null,
val base_url: String? = null,
val script: String? = null,
val context_from: List<String>? = null,
val enabled_toolsets: List<String>? = null,
val workdir: String? = null,
val no_agent: Boolean? = null,
val repeat: CronJobRepeat? = null,
) {
val scheduleText: String
get() =
@@ -0,0 +1,22 @@
package com.m57.hermescontrol.data.model
data class CreateCronJobRequest(
val name: String = "",
val schedule: String,
val prompt: String = "",
val deliver: String = "local",
val skills: List<String>? = null,
val model: String? = null,
val provider: String? = null,
val base_url: String? = null,
val script: String? = null,
val context_from: List<String>? = null,
val enabled_toolsets: List<String>? = null,
val workdir: String? = null,
val no_agent: Boolean = false,
val repeat: Int? = null,
)
data class UpdateCronJobRequest(
val updates: Map<String, Any?>,
)
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.DoctorResponse
@@ -38,6 +39,7 @@ import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
@@ -103,6 +105,22 @@ interface HermesApiService {
@Path("id") id: String,
): Response<Unit>
@GET("api/cron/jobs/{id}")
suspend fun getCronJob(
@Path("id") id: String,
): Response<CronJob>
@POST("api/cron/jobs")
suspend fun createCronJob(
@Body body: CreateCronJobRequest,
): Response<CronJob>
@PUT("api/cron/jobs/{id}")
suspend fun updateCronJob(
@Path("id") id: String,
@Body body: UpdateCronJobRequest,
): Response<CronJob>
@POST("api/cron/jobs/{id}/trigger")
suspend fun triggerCronJob(
@Path("id") id: String,
@@ -4,29 +4,48 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
@@ -63,6 +82,14 @@ fun CronJobsScreen(
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadCronJobs() },
actions = {
IconButton(onClick = { viewModel.openNewJobDialog() }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.cron_action_add),
)
}
},
) {
when {
state.isLoading && state.jobs.isEmpty() -> {
@@ -102,7 +129,7 @@ fun CronJobsScreen(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = job.name,
@@ -113,9 +140,7 @@ fun CronJobsScreen(
StatusBadge(
text =
if (job.state == "active") {
stringResource(
R.string.cron_status_active,
)
stringResource(R.string.cron_status_active)
} else {
stringResource(R.string.cron_status_paused)
},
@@ -144,6 +169,15 @@ fun CronJobsScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = { viewModel.openEditJobDialog(job.id) },
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.cron_action_edit),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (job.state == "active") {
IconButton(
onClick = { viewModel.pauseCronJob(job.id) },
@@ -191,4 +225,261 @@ fun CronJobsScreen(
}
}
}
// ── Editor Dialog ──
if (state.editorState.isOpen) {
CronJobEditorDialog(
state = state.editorState,
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
onToggleNoAgent = { viewModel.toggleNoAgent() },
onSave = { viewModel.saveEditor() },
onDismiss = { viewModel.closeEditor() },
onClearToast = { viewModel.clearEditorToast() },
)
}
}
@Composable
fun CronJobEditorDialog(
state: CronJobEditorState,
onFieldChange: (String, String) -> Unit,
onToggleNoAgent: () -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
onClearToast: () -> Unit,
) {
var showDiscardConfirm by remember { mutableStateOf(false) }
val hasChanges =
state.name.isNotEmpty() || state.schedule.isNotEmpty() ||
state.prompt.isNotEmpty() || state.skills.isNotEmpty()
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
Dialog(
onDismissRequest = {
if (hasChanges && !state.isNew) {
showDiscardConfirm = true
} else {
onDismiss()
}
},
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Card(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
HermesScaffold(
title = {
Text(
if (state.isNew) {
stringResource(R.string.cron_edit_title_new)
} else {
stringResource(R.string.cron_edit_title)
},
)
},
navigationIcon =
NavIcon.Back(
onBack = {
if (hasChanges && !state.isNew) {
showDiscardConfirm = true
} else {
onDismiss()
}
},
),
actions = {
if (!state.isLoading) {
IconButton(
onClick = onSave,
enabled = !state.isSaving,
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(0.6f),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
Text(
text = stringResource(R.string.cron_edit_action_save),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
}
}
},
) { padding ->
if (state.isLoading) {
LoadingState()
} else {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Name
OutlinedTextField(
value = state.name,
onValueChange = { onFieldChange("name", it) },
label = { Text(stringResource(R.string.cron_edit_field_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Schedule (required)
OutlinedTextField(
value = state.schedule,
onValueChange = { onFieldChange("schedule", it) },
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Prompt
OutlinedTextField(
value = state.prompt,
onValueChange = { onFieldChange("prompt", it) },
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
modifier =
Modifier
.fillMaxWidth()
.height(120.dp),
textStyle =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
)
// Delivery
OutlinedTextField(
value = state.deliver,
onValueChange = { onFieldChange("deliver", it) },
label = { Text(stringResource(R.string.cron_edit_field_deliver)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
supportingText = {
Text(
stringResource(R.string.cron_edit_hint_deliver),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
},
)
// Skills
OutlinedTextField(
value = state.skills,
onValueChange = { onFieldChange("skills", it) },
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
// Model
OutlinedTextField(
value = state.model,
onValueChange = { onFieldChange("model", it) },
label = { Text(stringResource(R.string.cron_edit_field_model)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Provider
OutlinedTextField(
value = state.provider,
onValueChange = { onFieldChange("provider", it) },
label = { Text(stringResource(R.string.cron_edit_field_provider)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Base URL
OutlinedTextField(
value = state.base_url,
onValueChange = { onFieldChange("base_url", it) },
label = { Text(stringResource(R.string.cron_edit_field_base_url)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Script path
OutlinedTextField(
value = state.script,
onValueChange = { onFieldChange("script", it) },
label = { Text(stringResource(R.string.cron_edit_field_script)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Work directory
OutlinedTextField(
value = state.workdir,
onValueChange = { onFieldChange("workdir", it) },
label = { Text(stringResource(R.string.cron_edit_field_workdir)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// No Agent toggle
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = state.no_agent,
onCheckedChange = { onToggleNoAgent() },
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.cron_edit_field_no_agent),
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}
if (showDiscardConfirm) {
AlertDialog(
onDismissRequest = { showDiscardConfirm = false },
title = { Text(stringResource(R.string.cron_discard_title)) },
text = { Text(stringResource(R.string.cron_discard_message)) },
confirmButton = {
TextButton(
onClick = {
showDiscardConfirm = false
onDismiss()
},
) {
Text(stringResource(R.string.action_discard))
}
},
dismissButton = {
TextButton(onClick = { showDiscardConfirm = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.cron
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
@@ -22,6 +24,30 @@ data class CronJobsUiState(
val jobs: List<CronJob> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
// Editor state
val editorState: CronJobEditorState = CronJobEditorState(),
)
data class CronJobEditorState(
val isOpen: Boolean = false,
val isNew: Boolean = false,
val jobId: String? = null,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val toastMessage: String? = null,
// Form fields
val name: String = "",
val schedule: String = "",
val prompt: String = "",
val deliver: String = "local",
val skills: String = "",
val model: String = "",
val provider: String = "",
val base_url: String = "",
val script: String = "",
val workdir: String = "",
val enabled: Boolean = true,
val no_agent: Boolean = false,
)
class CronJobsViewModel :
@@ -54,7 +80,6 @@ class CronJobsViewModel :
fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(
jobs =
@@ -76,7 +101,6 @@ class CronJobsViewModel :
fun resumeCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(
jobs =
@@ -106,7 +130,6 @@ class CronJobsViewModel :
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Job triggered successfully") }
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") }
}
@@ -116,7 +139,6 @@ class CronJobsViewModel :
fun deleteCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(jobs = state.jobs.filter { it.id != id })
}
@@ -131,6 +153,176 @@ class CronJobsViewModel :
}
}
// ── Editor ──
fun openNewJobDialog() {
_uiState.update {
it.copy(
editorState =
CronJobEditorState(
isOpen = true,
isNew = true,
),
)
}
}
fun openEditJobDialog(id: String) {
_uiState.update {
it.copy(
editorState =
CronJobEditorState(
isOpen = true,
isNew = false,
jobId = id,
isLoading = true,
),
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getCronJob(id) }
}
when (result) {
is NetworkResult.Success -> {
val job = result.data ?: return@launch
_uiState.update {
it.copy(
editorState =
CronJobEditorState(
isOpen = true,
isNew = false,
jobId = id,
name = job.name,
schedule = extractScheduleString(job),
prompt = job.prompt.orEmpty(),
deliver = job.deliver ?: "local",
skills = (job.skills ?: emptyList()).joinToString("\n"),
model = job.model.orEmpty(),
provider = job.provider.orEmpty(),
base_url = job.base_url.orEmpty(),
script = job.script.orEmpty(),
workdir = job.workdir.orEmpty(),
enabled = job.enabled ?: true,
no_agent = job.no_agent ?: false,
),
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
editorState =
CronJobEditorState(
isOpen = true,
isNew = false,
jobId = id,
toastMessage = "Failed to load job: ${result.error.message}",
),
)
}
}
}
}
}
fun closeEditor() {
_uiState.update { it.copy(editorState = CronJobEditorState()) }
}
fun updateEditorField(
name: String,
value: String,
) {
_uiState.update { state ->
state.copy(
editorState =
state.editorState.applyFieldChange(name, value).copy(
toastMessage = null,
),
)
}
}
fun saveEditor() {
val editor = _uiState.value.editorState
if (editor.schedule.isBlank()) {
_uiState.update { it.copy(editorState = editor.copy(toastMessage = "Schedule is required")) }
return
}
_uiState.update { it.copy(editorState = editor.copy(isSaving = true)) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
if (editor.isNew) {
safeApiCall {
ApiClient.hermesApi.createCronJob(
CreateCronJobRequest(
name = editor.name,
schedule = editor.schedule,
prompt = editor.prompt,
deliver = editor.deliver,
skills = parseLines(editor.skills),
model = editor.model.ifBlank { null },
provider = editor.provider.ifBlank { null },
base_url = editor.base_url.ifBlank { null },
script = editor.script.ifBlank { null },
workdir = editor.workdir.ifBlank { null },
no_agent = editor.no_agent,
),
)
}
} else {
val updates = mutableMapOf<String, Any?>()
if (editor.name.isNotEmpty()) updates["name"] = editor.name
updates["schedule"] = editor.schedule
updates["prompt"] = editor.prompt
updates["deliver"] = editor.deliver
updates["skills"] = parseLines(editor.skills)
updates["model"] = editor.model.ifBlank { null }
updates["provider"] = editor.provider.ifBlank { null }
updates["base_url"] = editor.base_url.ifBlank { null }
updates["script"] = editor.script.ifBlank { null }
updates["workdir"] = editor.workdir.ifBlank { null }
updates["no_agent"] = editor.no_agent
safeApiCall {
ApiClient.hermesApi.updateCronJob(
editor.jobId ?: "",
UpdateCronJobRequest(updates = updates),
)
}
}
}
when (result) {
is NetworkResult.Success -> {
closeEditor()
loadCronJobs()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
editorState =
it.editorState.copy(
isSaving = false,
toastMessage = "Failed to save: ${result.error.message}",
),
)
}
}
}
}
}
fun clearEditorToast() {
_uiState.update { it.copy(editorState = it.editorState.copy(toastMessage = null)) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
private fun revertJobs(
originalJobs: List<CronJob>,
errorMsg: String,
@@ -138,7 +330,41 @@ class CronJobsViewModel :
_uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
private fun extractScheduleString(job: CronJob): String {
val s = job.schedule
return when (s) {
is String -> s
is Map<*, *> -> (s["value"] as? String) ?: job.scheduleText
else -> job.scheduleText
}
}
private fun parseLines(skills: String): List<String> =
skills.lines()
.map { it.trim() }
.filter { it.isNotEmpty() }
private fun CronJobEditorState.applyFieldChange(
name: String,
value: String,
): CronJobEditorState =
when (name) {
"name" -> copy(name = value)
"schedule" -> copy(schedule = value)
"prompt" -> copy(prompt = value)
"deliver" -> copy(deliver = value)
"skills" -> copy(skills = value)
"model" -> copy(model = value)
"provider" -> copy(provider = value)
"base_url" -> copy(base_url = value)
"script" -> copy(script = value)
"workdir" -> copy(workdir = value)
else -> this
}
fun toggleNoAgent() {
_uiState.update {
it.copy(editorState = it.editorState.copy(no_agent = !it.editorState.no_agent))
}
}
}