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 package com.m57.hermescontrol.data.model
data class CronJobRepeat(
val times: Int? = null,
val completed: Int = 0,
)
data class CronJob( data class CronJob(
val id: String, val id: String,
val name: String, val name: String,
@@ -10,6 +15,20 @@ data class CronJob(
val schedule_display: String? = null, val schedule_display: String? = null,
val last_status: String? = null, val last_status: String? = null,
val next_run_at: 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 val scheduleText: String
get() = 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.AchievementsResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AgentPluginInstallBody 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.CreateTaskBody
import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.DoctorResponse 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.ToggleSkillRequest
import com.m57.hermescontrol.data.model.Toolset import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest 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.UpdateProfileModelRequest
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
@@ -103,6 +105,22 @@ interface HermesApiService {
@Path("id") id: String, @Path("id") id: String,
): Response<Unit> ): 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") @POST("api/cron/jobs/{id}/trigger")
suspend fun triggerCronJob( suspend fun triggerCronJob(
@Path("id") id: String, @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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete 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.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
@@ -63,6 +82,14 @@ fun CronJobsScreen(
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadCronJobs() }, onRefresh = { viewModel.loadCronJobs() },
actions = {
IconButton(onClick = { viewModel.openNewJobDialog() }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.cron_action_add),
)
}
},
) { ) {
when { when {
state.isLoading && state.jobs.isEmpty() -> { state.isLoading && state.jobs.isEmpty() -> {
@@ -102,7 +129,7 @@ fun CronJobsScreen(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = job.name, text = job.name,
@@ -113,9 +140,7 @@ fun CronJobsScreen(
StatusBadge( StatusBadge(
text = text =
if (job.state == "active") { if (job.state == "active") {
stringResource( stringResource(R.string.cron_status_active)
R.string.cron_status_active,
)
} else { } else {
stringResource(R.string.cron_status_paused) stringResource(R.string.cron_status_paused)
}, },
@@ -144,6 +169,15 @@ fun CronJobsScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, 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") { if (job.state == "active") {
IconButton( IconButton(
onClick = { viewModel.pauseCronJob(job.id) }, 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronJob 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.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
@@ -22,6 +24,30 @@ data class CronJobsUiState(
val jobs: List<CronJob> = emptyList(), val jobs: List<CronJob> = emptyList(),
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: 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 : class CronJobsViewModel :
@@ -54,7 +80,6 @@ class CronJobsViewModel :
fun pauseCronJob(id: String) { fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
jobs = jobs =
@@ -76,7 +101,6 @@ class CronJobsViewModel :
fun resumeCronJob(id: String) { fun resumeCronJob(id: String) {
val originalJobs = _uiState.value.jobs val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
jobs = jobs =
@@ -106,7 +130,6 @@ class CronJobsViewModel :
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Job triggered successfully") } _uiState.update { it.copy(toastMessage = "Job triggered successfully") }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") }
} }
@@ -116,7 +139,6 @@ class CronJobsViewModel :
fun deleteCronJob(id: String) { fun deleteCronJob(id: String) {
val originalJobs = _uiState.value.jobs val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state -> _uiState.update { state ->
state.copy(jobs = state.jobs.filter { it.id != id }) 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( private fun revertJobs(
originalJobs: List<CronJob>, originalJobs: List<CronJob>,
errorMsg: String, errorMsg: String,
@@ -138,7 +330,41 @@ class CronJobsViewModel :
_uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) } _uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) }
} }
override fun clearToast() { private fun extractScheduleString(job: CronJob): String {
_uiState.update { it.copy(toastMessage = null) } 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))
}
} }
} }
+22
View File
@@ -364,6 +364,28 @@
<string name="cron_action_pause">Pause</string> <string name="cron_action_pause">Pause</string>
<string name="cron_action_resume">Resume</string> <string name="cron_action_resume">Resume</string>
<string name="cron_action_run">Run</string> <string name="cron_action_run">Run</string>
<string name="cron_action_edit">Edit</string>
<string name="cron_action_add">Add cron job</string>
<string name="cron_edit_title">Edit Cron Job</string>
<string name="cron_edit_title_new">New Cron Job</string>
<string name="cron_edit_field_name">Name</string>
<string name="cron_edit_field_schedule">Schedule</string>
<string name="cron_edit_field_prompt">Prompt</string>
<string name="cron_edit_field_deliver">Delivery</string>
<string name="cron_edit_field_skills">Skills</string>
<string name="cron_edit_field_model">Model</string>
<string name="cron_edit_field_provider">Provider</string>
<string name="cron_edit_field_base_url">Base URL</string>
<string name="cron_edit_field_script">Script</string>
<string name="cron_edit_field_workdir">Work Directory</string>
<string name="cron_edit_field_no_agent">No agent (script-only)</string>
<string name="cron_edit_hint_schedule">e.g. 0 9 * * * or every 2h</string>
<string name="cron_edit_hint_deliver">e.g. origin, local, all, or telegram:chat_id</string>
<string name="cron_edit_hint_skills">One per line</string>
<string name="cron_edit_action_save">Save</string>
<string name="cron_edit_error_schedule_required">Schedule is required</string>
<string name="cron_discard_title">Discard changes?</string>
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
<!-- Achievements Screen --> <!-- Achievements Screen -->
<string name="achievements_empty_title">No achievements yet</string> <string name="achievements_empty_title">No achievements yet</string>