feat(cron): visual schedule builder with timepicker and interval presets

This commit is contained in:
yilsem committed 2026-10-08 02:17:07 +03:00
1 parent 5006d1c7a8
commit 1d11afefec
5 files changed
+748 -27

No files matched your search

@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.cron package com.m57.hermescontrol.ui.cron
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement 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
@@ -9,6 +10,7 @@ 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.size
import androidx.compose.foundation.layout.width 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
@@ -16,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.Add
import androidx.compose.material.icons.filled.Code
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.Edit
import androidx.compose.material.icons.filled.Pause 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.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
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.OutlinedCard
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberTimePickerState
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
@@ -388,6 +395,12 @@ fun CronJobEditorDialog(
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast) ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
LaunchedEffect(state.isNew) {
if (state.isNew && state.schedule.isBlank()) {
onFieldChange("schedule", "0 9 * * *")
}
}
Dialog( Dialog(
onDismissRequest = { onDismissRequest = {
if (hasChanges && !state.isNew) { if (hasChanges && !state.isNew) {
@@ -504,13 +517,9 @@ fun CronJobEditorDialog(
) )
// Schedule (required) // Schedule (required)
OutlinedTextField( SchedulePickerSection(
value = state.schedule, schedule = state.schedule,
onValueChange = { onFieldChange("schedule", it) }, onScheduleChange = { 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 // Prompt
@@ -837,13 +846,84 @@ private fun BlueprintSlotField(
) )
} }
field.type == "enum" || field.type == "weekdays" -> { field.type == "enum" || field.type == "weekdays" -> {
ExposedDropdownField( ExposedDropdownField(
label = field.label, label = field.label,
options = field.options, options = field.options,
selectedValue = value, selectedValue = value,
onOptionSelected = onValueChange, 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 -> { 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<Int> = 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<Int> = 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<Int> {
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()
}
@@ -24,6 +24,22 @@ object CronExpressionFormatter {
fun cronToHumanReadable(schedule: String): String { fun cronToHumanReadable(schedule: String): String {
val clean = schedule.trim().replace("\\s+".toRegex(), " ") 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(" ") val parts = clean.split(" ")
if (parts.size != 5) { if (parts.size != 5) {
return schedule return schedule
@@ -134,9 +150,13 @@ object CronExpressionFormatter {
} }
val dows = parseField(dowPart, 0, 7) val dows = parseField(dowPart, 0, 7)
if (dows.size == 1) { if (dows.isNotEmpty()) {
val dowName = dowNames[dows[0]] val names = dows.map { dowNames[it] }.distinct()
return "Every $dowName at $timeStr" return if (names.size == 1) {
"Every " + names[0] + " at " + timeStr
} else {
"Every " + names.joinToString(", ") + " at " + timeStr
}
} }
return null return null
+27 -4
View File
@@ -1288,11 +1288,34 @@
<string name="cron_edit_hint_skills">Her satıra bir tane</string> <string name="cron_edit_hint_skills">Her satıra bir tane</string>
<string name="cron_edit_action_save">Kaydet</string> <string name="cron_edit_action_save">Kaydet</string>
<string name="cron_discard_title">Değişiklikler atılsın mı?</string> <string name="cron_discard_title">Değişiklikler atılsın mı?</string>
<string name="cron_discard_message">Kaydedilmemiş değişiklikler var. Atılsın mı?</string> <string name="cron_discard_message">Kaydedilmemiş değişiklikler var. Atılsın mı?</string>
<string name="cron_delete_title">Görev silinsin mi?</string> <string name="cron_delete_title">Görev silinsin mi?</string>
<string name="cron_delete_message">\"%1$s\" silinsin mi? Bu işlem geri alınamaz.</string> <string name="cron_delete_message">\"%1$s\" silinsin mi? Bu işlem geri alınamaz.</string>
<string name="cron_schedule_mode_interval">Aralık</string>
<string name="cron_schedule_mode_daily">Her Gün</string>
<string name="cron_schedule_mode_weekly">Haftalık</string>
<string name="cron_schedule_mode_monthly">Aylık</string>
<string name="cron_schedule_mode_advanced">Gelişmiş</string>
<string name="cron_schedule_select_time">Çalışma Saati</string>
<string name="cron_schedule_advanced_toggle">Cron ifadesini elle düzenle</string>
<string name="cron_schedule_visual_toggle">Görsel zamanlayıcıya dön</string>
<string name="cron_schedule_weekdays_workdays">Hafta içi</string>
<string name="cron_schedule_weekdays_weekend">Hafta sonu</string>
<string name="cron_schedule_weekdays_all">Her gün</string>
<string name="cron_schedule_interval_presets">Hızlı aralıklar</string>
<string name="cron_schedule_day_of_month_label">Ayın %1$d. günü</string>
<string name="cron_dow_mon">Pzt</string>
<string name="cron_dow_tue">Sal</string>
<string name="cron_dow_wed">Çar</string>
<string name="cron_dow_thu">Per</string>
<string name="cron_dow_fri">Cum</string>
<string name="cron_dow_sat">Cmt</string>
<string name="cron_dow_sun">Paz</string>
<string name="cron_unit_minute">dk</string>
<string name="cron_unit_hour">saat</string>
<string name="cron_unit_day">gün</string>
<!-- Achievements Screen --> <!-- Achievements Screen -->
<string name="achievements_empty_title">Henüz başarım yok</string> <string name="achievements_empty_title">Henüz başarım yok</string>
<string name="achievements_empty_desc">Başarım kazanmak için görevleri tamamlayın.</string> <string name="achievements_empty_desc">Başarım kazanmak için görevleri tamamlayın.</string>
<string name="achievements_label_progress">İlerleme: %1$d / %2$d</string> <string name="achievements_label_progress">İlerleme: %1$d / %2$d</string>
+27 -4
View File
@@ -1288,11 +1288,34 @@
<string name="cron_edit_hint_skills">One per line</string> <string name="cron_edit_hint_skills">One per line</string>
<string name="cron_edit_action_save">Save</string> <string name="cron_edit_action_save">Save</string>
<string name="cron_discard_title">Discard changes?</string> <string name="cron_discard_title">Discard changes?</string>
<string name="cron_discard_message">You have unsaved changes. Discard them?</string> <string name="cron_discard_message">You have unsaved changes. Discard them?</string>
<string name="cron_delete_title">Delete job?</string> <string name="cron_delete_title">Delete job?</string>
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string> <string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
<string name="cron_schedule_mode_interval">Interval</string>
<string name="cron_schedule_mode_daily">Daily</string>
<string name="cron_schedule_mode_weekly">Weekly</string>
<string name="cron_schedule_mode_monthly">Monthly</string>
<string name="cron_schedule_mode_advanced">Advanced</string>
<string name="cron_schedule_select_time">Execution Time</string>
<string name="cron_schedule_advanced_toggle">Edit raw cron expression</string>
<string name="cron_schedule_visual_toggle">Switch to visual scheduler</string>
<string name="cron_schedule_weekdays_workdays">Weekdays</string>
<string name="cron_schedule_weekdays_weekend">Weekend</string>
<string name="cron_schedule_weekdays_all">Every day</string>
<string name="cron_schedule_interval_presets">Quick intervals</string>
<string name="cron_schedule_day_of_month_label">Day of month: %1$d</string>
<string name="cron_dow_mon">Mon</string>
<string name="cron_dow_tue">Tue</string>
<string name="cron_dow_wed">Wed</string>
<string name="cron_dow_thu">Thu</string>
<string name="cron_dow_fri">Fri</string>
<string name="cron_dow_sat">Sat</string>
<string name="cron_dow_sun">Sun</string>
<string name="cron_unit_minute">min</string>
<string name="cron_unit_hour">hr</string>
<string name="cron_unit_day">day</string>
<!-- Achievements Screen --> <!-- Achievements Screen -->
<string name="achievements_empty_title">No achievements yet</string> <string name="achievements_empty_title">No achievements yet</string>
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string> <string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
<string name="achievements_label_progress">Progress: %1$d / %2$d</string> <string name="achievements_label_progress">Progress: %1$d / %2$d</string>
@@ -29,9 +29,13 @@ class CronExpressionFormatterTest {
assertEquals("Every 15 minutes", CronExpressionFormatter.cronToHumanReadable("*/15 * * * *")) assertEquals("Every 15 minutes", CronExpressionFormatter.cronToHumanReadable("*/15 * * * *"))
assertEquals("Every Monday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1")) assertEquals("Every Monday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1"))
assertEquals("Every Sunday at 06:00", CronExpressionFormatter.cronToHumanReadable("0 6 * * 0")) 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 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("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *"))
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *")) 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 @Test