feat(cron): visual schedule builder with timepicker and interval presets
This commit is contained in:
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
|
||||
|
||||
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<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 {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user