diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt index 1933b671..0689d8e9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt @@ -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? = null, + val model: String? = null, + val provider: String? = null, + val base_url: String? = null, + val script: String? = null, + val context_from: List? = null, + val enabled_toolsets: List? = null, + val workdir: String? = null, + val no_agent: Boolean? = null, + val repeat: CronJobRepeat? = null, ) { val scheduleText: String get() = diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronJobRequests.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronJobRequests.kt new file mode 100644 index 00000000..035642db --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronJobRequests.kt @@ -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? = null, + val model: String? = null, + val provider: String? = null, + val base_url: String? = null, + val script: String? = null, + val context_from: List? = null, + val enabled_toolsets: List? = null, + val workdir: String? = null, + val no_agent: Boolean = false, + val repeat: Int? = null, +) + +data class UpdateCronJobRequest( + val updates: Map, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index a4d0fc4e..5c4f009e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + @GET("api/cron/jobs/{id}") + suspend fun getCronJob( + @Path("id") id: String, + ): Response + + @POST("api/cron/jobs") + suspend fun createCronJob( + @Body body: CreateCronJobRequest, + ): Response + + @PUT("api/cron/jobs/{id}") + suspend fun updateCronJob( + @Path("id") id: String, + @Body body: UpdateCronJobRequest, + ): Response + @POST("api/cron/jobs/{id}/trigger") suspend fun triggerCronJob( @Path("id") id: String, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index af8cf83e..80e794e0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -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)) + } + }, + ) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index b067d911..549bf148 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -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 = 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() + 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, 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 = + 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)) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3bb643f0..ad95238e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -364,6 +364,28 @@ Pause Resume Run + Edit + Add cron job + Edit Cron Job + New Cron Job + Name + Schedule + Prompt + Delivery + Skills + Model + Provider + Base URL + Script + Work Directory + No agent (script-only) + e.g. 0 9 * * * or every 2h + e.g. origin, local, all, or telegram:chat_id + One per line + Save + Schedule is required + Discard changes? + You have unsaved changes. Discard them? No achievements yet