feat(cron): visual schedule builder with timepicker and interval presets
This commit is contained in:
1 parent
5006d1c7a8
commit
1d11afefec
5 files changed
+731
-10
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
|
||||||
@@ -846,6 +855,77 @@ private fun BlueprintSlotField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 -> {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1291,6 +1291,29 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1291,6 +1291,29 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ class CronExpressionFormatterTest {
|
|||||||
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
|
||||||
|
|||||||
Reference in new issue
Block a user