From 1d11afefec7afb649fdcb3109c06fb3432b92438 Mon Sep 17 00:00:00 2001 From: yilsem Date: Thu, 8 Oct 2026 02:17:07 +0300 Subject: [PATCH] feat(cron): visual schedule builder with timepicker and interval presets --- .../hermescontrol/ui/cron/CronJobsScreen.kt | 677 +++++++++++++++++- .../util/CronExpressionFormatter.kt | 26 +- app/src/main/res/values-tr/strings.xml | 31 +- app/src/main/res/values/strings.xml | 31 +- .../util/CronExpressionFormatterTest.kt | 10 +- 5 files changed, 748 insertions(+), 27 deletions(-) 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 ddbc7235..1933bc56 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 @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.cron import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,6 +10,7 @@ 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -16,6 +18,7 @@ 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.Code import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Pause @@ -32,12 +35,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -388,6 +395,12 @@ fun CronJobEditorDialog( ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast) + LaunchedEffect(state.isNew) { + if (state.isNew && state.schedule.isBlank()) { + onFieldChange("schedule", "0 9 * * *") + } + } + Dialog( onDismissRequest = { if (hasChanges && !state.isNew) { @@ -504,13 +517,9 @@ fun CronJobEditorDialog( ) // 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, + SchedulePickerSection( + schedule = state.schedule, + onScheduleChange = { onFieldChange("schedule", it) }, ) // Prompt @@ -837,13 +846,84 @@ private fun BlueprintSlotField( ) } - field.type == "enum" || field.type == "weekdays" -> { - ExposedDropdownField( - label = field.label, - options = field.options, - selectedValue = value, - onOptionSelected = onValueChange, + field.type == "enum" || field.type == "weekdays" -> { + ExposedDropdownField( + label = field.label, + options = field.options, + selectedValue = value, + onOptionSelected = onValueChange, + ) + } + + field.type == "time" -> { + var showTimePicker by remember { mutableStateOf(false) } + val timeParts = value.split(":").mapNotNull { it.toIntOrNull() } + val curHour = if (timeParts.isNotEmpty()) timeParts[0] else 8 + val curMin = if (timeParts.size > 1) timeParts[1] else 0 + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.label) }, + placeholder = { Text(stringResource(R.string.cron_edit_hint_time)) }, + trailingIcon = { + IconButton(onClick = { showTimePicker = true }) { + Icon( + imageVector = Icons.Filled.Schedule, + contentDescription = stringResource(R.string.cron_schedule_select_time), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + supportingText = + if (field.help.isNotBlank()) { + { + Text( + text = field.help, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } else { + null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, ) + + if (showTimePicker) { + val timePickerState = + rememberTimePickerState( + initialHour = curHour, + initialMinute = curMin, + is24Hour = true, + ) + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton( + onClick = { + onValueChange(String.format("%02d:%02d", timePickerState.hour, timePickerState.minute)) + showTimePicker = false + }, + ) { + Text(stringResource(R.string.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TimePicker(state = timePickerState) + } + }, + ) + } } else -> { @@ -936,3 +1016,574 @@ private fun DeliverField( } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SchedulePickerSection( + schedule: String, + onScheduleChange: (String) -> Unit, +) { + val parsed = remember(schedule) { parseSchedule(schedule) } + + var mode by remember(schedule) { mutableStateOf(parsed.mode) } + var hour by remember(schedule) { mutableStateOf(parsed.hour) } + var minute by remember(schedule) { mutableStateOf(parsed.minute) } + var intervalCount by remember(schedule) { mutableStateOf(parsed.intervalCount) } + var intervalUnit by remember(schedule) { mutableStateOf(parsed.intervalUnit) } + var selectedDays by remember(schedule) { mutableStateOf(parsed.selectedDays) } + var dayOfMonth by remember(schedule) { mutableStateOf(parsed.dayOfMonth) } + var isAdvanced by remember(schedule) { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) } + var showTimePicker by remember { mutableStateOf(false) } + + fun emitSchedule( + newMode: ScheduleMode, + newHour: Int = hour, + newMinute: Int = minute, + newIntervalCount: Int = intervalCount, + newIntervalUnit: String = intervalUnit, + newDays: Set = selectedDays, + newDom: Int = dayOfMonth, + ) { + val built = + when (newMode) { + ScheduleMode.DAILY -> "$newMinute $newHour * * *" + ScheduleMode.WEEKLY -> { + val daysStr = (if (newDays.isEmpty()) listOf(1) else newDays.sorted()).joinToString(",") + "$newMinute $newHour * * $daysStr" + } + ScheduleMode.MONTHLY -> "$newMinute $newHour $newDom * *" + ScheduleMode.INTERVAL -> "every $newIntervalCount$newIntervalUnit" + ScheduleMode.ADVANCED -> schedule + } + onScheduleChange(built) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.cron_edit_field_schedule), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + TextButton( + onClick = { + isAdvanced = !isAdvanced + if (isAdvanced) { + mode = ScheduleMode.ADVANCED + } else { + val fallback = parseSchedule(schedule) + mode = if (fallback.mode == ScheduleMode.ADVANCED) ScheduleMode.DAILY else fallback.mode + emitSchedule(mode) + } + }, + ) { + Icon( + imageVector = if (isAdvanced) Icons.Filled.Schedule else Icons.Filled.Code, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + if (isAdvanced) { + stringResource(R.string.cron_schedule_visual_toggle) + } else { + stringResource(R.string.cron_schedule_advanced_toggle) + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + + if (isAdvanced) { + OutlinedTextField( + value = schedule, + onValueChange = onScheduleChange, + label = { Text(stringResource(R.string.cron_edit_field_schedule)) }, + placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } else { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = mode == ScheduleMode.DAILY, + onClick = { + mode = ScheduleMode.DAILY + emitSchedule(ScheduleMode.DAILY) + }, + label = { Text(stringResource(R.string.cron_schedule_mode_daily)) }, + ) + FilterChip( + selected = mode == ScheduleMode.WEEKLY, + onClick = { + mode = ScheduleMode.WEEKLY + emitSchedule(ScheduleMode.WEEKLY) + }, + label = { Text(stringResource(R.string.cron_schedule_mode_weekly)) }, + ) + FilterChip( + selected = mode == ScheduleMode.INTERVAL, + onClick = { + mode = ScheduleMode.INTERVAL + emitSchedule(ScheduleMode.INTERVAL) + }, + label = { Text(stringResource(R.string.cron_schedule_mode_interval)) }, + ) + FilterChip( + selected = mode == ScheduleMode.MONTHLY, + onClick = { + mode = ScheduleMode.MONTHLY + emitSchedule(ScheduleMode.MONTHLY) + }, + label = { Text(stringResource(R.string.cron_schedule_mode_monthly)) }, + ) + } + + when (mode) { + ScheduleMode.DAILY -> { + TimeDisplayCard( + hour = hour, + minute = minute, + onClick = { showTimePicker = true }, + ) + } + + ScheduleMode.WEEKLY -> { + TimeDisplayCard( + hour = hour, + minute = minute, + onClick = { showTimePicker = true }, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + TextButton( + onClick = { + selectedDays = setOf(1, 2, 3, 4, 5) + emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays) + }, + ) { + Text(stringResource(R.string.cron_schedule_weekdays_workdays), style = MaterialTheme.typography.labelSmall) + } + TextButton( + onClick = { + selectedDays = setOf(6, 0) + emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays) + }, + ) { + Text(stringResource(R.string.cron_schedule_weekdays_weekend), style = MaterialTheme.typography.labelSmall) + } + TextButton( + onClick = { + selectedDays = setOf(1, 2, 3, 4, 5, 6, 0) + emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays) + }, + ) { + Text(stringResource(R.string.cron_schedule_weekdays_all), style = MaterialTheme.typography.labelSmall) + } + } + + val daysConfig = + listOf( + 1 to R.string.cron_dow_mon, + 2 to R.string.cron_dow_tue, + 3 to R.string.cron_dow_wed, + 4 to R.string.cron_dow_thu, + 5 to R.string.cron_dow_fri, + 6 to R.string.cron_dow_sat, + 0 to R.string.cron_dow_sun, + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + daysConfig.forEach { (d, labelRes) -> + val isSelected = selectedDays.contains(d) + FilterChip( + selected = isSelected, + onClick = { + val nextDays = + if (isSelected) { + if (selectedDays.size > 1) selectedDays - d else selectedDays + } else { + selectedDays + d + } + selectedDays = nextDays + emitSchedule(ScheduleMode.WEEKLY, newDays = nextDays) + }, + label = { Text(stringResource(labelRes)) }, + ) + } + } + } + } + + ScheduleMode.INTERVAL -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.cron_schedule_interval_presets), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val presets = + listOf( + Triple(15, "m", "15 " + stringResource(R.string.cron_unit_minute)), + Triple(30, "m", "30 " + stringResource(R.string.cron_unit_minute)), + Triple(1, "h", "1 " + stringResource(R.string.cron_unit_hour)), + Triple(2, "h", "2 " + stringResource(R.string.cron_unit_hour)), + Triple(6, "h", "6 " + stringResource(R.string.cron_unit_hour)), + Triple(12, "h", "12 " + stringResource(R.string.cron_unit_hour)), + Triple(1, "d", "1 " + stringResource(R.string.cron_unit_day)), + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + presets.forEach { (cnt, unt, title) -> + val isSelected = intervalCount == cnt && intervalUnit == unt + FilterChip( + selected = isSelected, + onClick = { + intervalCount = cnt + intervalUnit = unt + emitSchedule(ScheduleMode.INTERVAL, newIntervalCount = cnt, newIntervalUnit = unt) + }, + label = { Text(title) }, + ) + } + } + } + } + + ScheduleMode.MONTHLY -> { + TimeDisplayCard( + hour = hour, + minute = minute, + onClick = { showTimePicker = true }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.cron_schedule_day_of_month_label, dayOfMonth), + style = MaterialTheme.typography.bodyMedium, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + onClick = { + if (dayOfMonth > 1) { + dayOfMonth-- + emitSchedule(ScheduleMode.MONTHLY, newDom = dayOfMonth) + } + }, + enabled = dayOfMonth > 1, + ) { + Text("-", style = MaterialTheme.typography.titleLarge) + } + Text( + text = dayOfMonth.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + IconButton( + onClick = { + if (dayOfMonth < 31) { + dayOfMonth++ + emitSchedule(ScheduleMode.MONTHLY, newDom = dayOfMonth) + } + }, + enabled = dayOfMonth < 31, + ) { + Text("+", style = MaterialTheme.typography.titleLarge) + } + } + } + } + + ScheduleMode.ADVANCED -> Unit + } + } + + // Live Summary Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Schedule, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = CronExpressionFormatter.cronToHumanReadable(schedule.ifBlank { "0 9 * * *" }), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = schedule.ifBlank { "0 9 * * *" }, + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + } + + if (showTimePicker) { + val timePickerState = + rememberTimePickerState( + initialHour = hour, + initialMinute = minute, + is24Hour = true, + ) + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton( + onClick = { + hour = timePickerState.hour + minute = timePickerState.minute + showTimePicker = false + emitSchedule(mode, newHour = timePickerState.hour, newMinute = timePickerState.minute) + }, + ) { + Text(stringResource(R.string.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TimePicker(state = timePickerState) + } + }, + ) + } +} + +@Composable +private fun TimeDisplayCard( + hour: Int, + minute: Int, + onClick: () -> Unit, +) { + OutlinedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Schedule, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = stringResource(R.string.cron_schedule_select_time), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = String.format("%02d:%02d", hour, minute), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + } + Text( + text = stringResource(R.string.cron_action_edit), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +private enum class ScheduleMode { + DAILY, + WEEKLY, + INTERVAL, + MONTHLY, + ADVANCED, +} + +private data class ParsedSchedule( + val mode: ScheduleMode, + val hour: Int = 9, + val minute: Int = 0, + val intervalCount: Int = 1, + val intervalUnit: String = "h", + val selectedDays: Set = setOf(1, 2, 3, 4, 5), + val dayOfMonth: Int = 1, + val raw: String = "", +) + +private fun parseSchedule(schedule: String): ParsedSchedule { + val clean = schedule.trim().replace("\\s+".toRegex(), " ") + if (clean.isBlank()) { + return ParsedSchedule(mode = ScheduleMode.DAILY, hour = 9, minute = 0) + } + + val everyMatch = Regex("""^every\s+(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(clean) + if (everyMatch != null) { + val count = everyMatch.groupValues[1].toIntOrNull() ?: 1 + val unit = everyMatch.groupValues[2].lowercase() + return ParsedSchedule( + mode = ScheduleMode.INTERVAL, + intervalCount = count, + intervalUnit = unit, + ) + } + + val minStepMatch = Regex("""^\*/(\d+)\s+\*\s+\*\s+\*\s+\*$""").find(clean) + if (minStepMatch != null) { + val count = minStepMatch.groupValues[1].toIntOrNull() ?: 15 + return ParsedSchedule( + mode = ScheduleMode.INTERVAL, + intervalCount = count, + intervalUnit = "m", + ) + } + + val hourStepMatch = Regex("""^0\s+\*/(\d+)\s+\*\s+\*\s+\*$""").find(clean) + if (hourStepMatch != null) { + val count = hourStepMatch.groupValues[1].toIntOrNull() ?: 1 + return ParsedSchedule( + mode = ScheduleMode.INTERVAL, + intervalCount = count, + intervalUnit = "h", + ) + } + + val parts = clean.split(" ") + if (parts.size == 5) { + val minute = parts[0].toIntOrNull() + val hour = parts[1].toIntOrNull() + val dom = parts[2] + val month = parts[3] + val dow = parts[4] + + if (minute != null && hour != null && minute in 0..59 && hour in 0..23) { + if (dom == "*" && month == "*" && dow == "*") { + return ParsedSchedule( + mode = ScheduleMode.DAILY, + hour = hour, + minute = minute, + ) + } + if (dom == "*" && month == "*" && dow != "*") { + val days = parseDowList(dow) + if (days.isNotEmpty()) { + return ParsedSchedule( + mode = ScheduleMode.WEEKLY, + hour = hour, + minute = minute, + selectedDays = days.toSet(), + ) + } + } + if (dom != "*" && month == "*" && dow == "*") { + val domInt = dom.toIntOrNull() + if (domInt != null && domInt in 1..31) { + return ParsedSchedule( + mode = ScheduleMode.MONTHLY, + hour = hour, + minute = minute, + dayOfMonth = domInt, + ) + } + } + } + } + + return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule) +} + +private fun parseDowList(dow: String): List { + if (dow.contains("-")) { + val bounds = dow.split("-") + if (bounds.size == 2) { + val start = bounds[0].toIntOrNull() ?: return emptyList() + val end = bounds[1].toIntOrNull() ?: return emptyList() + if (start in 0..7 && end in 0..7 && start <= end) { + return (start..end).map { if (it == 7) 0 else it }.distinct().sorted() + } + } + } + return dow.split(",") + .mapNotNull { it.trim().toIntOrNull() } + .filter { it in 0..7 } + .map { if (it == 7) 0 else it } + .distinct() + .sorted() +} diff --git a/app/src/main/java/com/m57/hermescontrol/util/CronExpressionFormatter.kt b/app/src/main/java/com/m57/hermescontrol/util/CronExpressionFormatter.kt index aea00697..62353dc4 100644 --- a/app/src/main/java/com/m57/hermescontrol/util/CronExpressionFormatter.kt +++ b/app/src/main/java/com/m57/hermescontrol/util/CronExpressionFormatter.kt @@ -24,6 +24,22 @@ object CronExpressionFormatter { fun cronToHumanReadable(schedule: String): String { val clean = schedule.trim().replace("\\s+".toRegex(), " ") + if (clean.startsWith("every ", ignoreCase = true)) { + val rest = clean.substring(6).trim() + val match = Regex("""^(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(rest) + if (match != null) { + val num = match.groupValues[1].toIntOrNull() ?: 1 + val unit = match.groupValues[2].lowercase() + val unitStr = when (unit) { + "m" -> if (num == 1) "minute" else "minutes" + "h" -> if (num == 1) "hour" else "hours" + "d" -> if (num == 1) "day" else "days" + else -> unit + } + return "Every " + num + " " + unitStr + } + return schedule + } val parts = clean.split(" ") if (parts.size != 5) { return schedule @@ -134,9 +150,13 @@ object CronExpressionFormatter { } val dows = parseField(dowPart, 0, 7) - if (dows.size == 1) { - val dowName = dowNames[dows[0]] - return "Every $dowName at $timeStr" + if (dows.isNotEmpty()) { + val names = dows.map { dowNames[it] }.distinct() + return if (names.size == 1) { + "Every " + names[0] + " at " + timeStr + } else { + "Every " + names.joinToString(", ") + " at " + timeStr + } } return null diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0e2957a5..e3641805 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1288,11 +1288,34 @@ Her satıra bir tane Kaydet Değişiklikler atılsın mı? - Kaydedilmemiş değişiklikler var. Atılsın mı? - Görev silinsin mi? - \"%1$s\" silinsin mi? Bu işlem geri alınamaz. + Kaydedilmemiş değişiklikler var. Atılsın mı? + Görev silinsin mi? + \"%1$s\" silinsin mi? Bu işlem geri alınamaz. + Aralık + Her Gün + Haftalık + Aylık + Gelişmiş + Çalışma Saati + Cron ifadesini elle düzenle + Görsel zamanlayıcıya dön + Hafta içi + Hafta sonu + Her gün + Hızlı aralıklar + Ayın %1$d. günü + Pzt + Sal + Çar + Per + Cum + Cmt + Paz + dk + saat + gün - + Henüz başarım yok Başarım kazanmak için görevleri tamamlayın. İlerleme: %1$d / %2$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ffa1134..eda143af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1288,11 +1288,34 @@ One per line Save Discard changes? - You have unsaved changes. Discard them? - Delete job? - Delete \"%1$s\"? This can\'t be undone. + You have unsaved changes. Discard them? + Delete job? + Delete \"%1$s\"? This can\'t be undone. + Interval + Daily + Weekly + Monthly + Advanced + Execution Time + Edit raw cron expression + Switch to visual scheduler + Weekdays + Weekend + Every day + Quick intervals + Day of month: %1$d + Mon + Tue + Wed + Thu + Fri + Sat + Sun + min + hr + day - + No achievements yet Complete tasks to unlock achievements. Progress: %1$d / %2$d diff --git a/app/src/test/java/com/m57/hermescontrol/util/CronExpressionFormatterTest.kt b/app/src/test/java/com/m57/hermescontrol/util/CronExpressionFormatterTest.kt index 34816670..54182f67 100644 --- a/app/src/test/java/com/m57/hermescontrol/util/CronExpressionFormatterTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/util/CronExpressionFormatterTest.kt @@ -29,9 +29,13 @@ class CronExpressionFormatterTest { assertEquals("Every 15 minutes", CronExpressionFormatter.cronToHumanReadable("*/15 * * * *")) assertEquals("Every Monday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1")) assertEquals("Every Sunday at 06:00", CronExpressionFormatter.cronToHumanReadable("0 6 * * 0")) - assertEquals("On the 1st of every month at 12:00", CronExpressionFormatter.cronToHumanReadable("0 12 1 * *")) - assertEquals("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *")) - assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *")) + assertEquals("On the 1st of every month at 12:00", CronExpressionFormatter.cronToHumanReadable("0 12 1 * *")) + assertEquals("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *")) + assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *")) + assertEquals("Every 10 minutes", CronExpressionFormatter.cronToHumanReadable("every 10m")) + assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("every 2h")) + assertEquals("Every 1 day", CronExpressionFormatter.cronToHumanReadable("every 1d")) + assertEquals("Every Monday, Wednesday, Friday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1,3,5")) } @Test