fix(cron): add repeat dropdown and safe touch time selection
This commit is contained in:
1 parent
1d11afefec
commit
8161f3bf09
6 files changed
+454
-217
No files matched your search
@@ -0,0 +1,77 @@
|
|||||||
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SchedulePickerTest {
|
||||||
|
@get:Rule val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun repeatDropdownAndClockPreserveSchedule() {
|
||||||
|
var schedule by mutableStateOf("59 23 * * *")
|
||||||
|
val baseContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val configuration = Configuration(baseContext.resources.configuration).apply {
|
||||||
|
setLocale(java.util.Locale.forLanguageTag("tr-TR"))
|
||||||
|
}
|
||||||
|
val context = baseContext.createConfigurationContext(configuration)
|
||||||
|
compose.setContent {
|
||||||
|
CompositionLocalProvider(LocalContext provides context, LocalConfiguration provides configuration) {
|
||||||
|
MaterialTheme {
|
||||||
|
Surface {
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
|
SchedulePickerSection(schedule) { schedule = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun text(id: Int) = context.getString(id)
|
||||||
|
fun screenshot(name: String) {
|
||||||
|
compose.waitForIdle()
|
||||||
|
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
|
||||||
|
File(context.getExternalFilesDir(null), "$name.png").outputStream().use {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
screenshot("schedule-daily-360")
|
||||||
|
compose.onNodeWithText("23:59").performClick()
|
||||||
|
screenshot("schedule-clock-360")
|
||||||
|
compose.onNodeWithText("59").performClick()
|
||||||
|
compose.onNodeWithText("55").performClick()
|
||||||
|
compose.onNodeWithText(text(R.string.action_ok)).performClick()
|
||||||
|
compose.runOnIdle { assertEquals("55 23 * * *", schedule) }
|
||||||
|
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||||
|
screenshot("schedule-repeat-360")
|
||||||
|
compose.onAllNodesWithText(text(R.string.cron_repeat_yearly))[0].performClick()
|
||||||
|
compose.runOnIdle { assertEquals("55 23 1 1 *", schedule) }
|
||||||
|
screenshot("schedule-yearly-360")
|
||||||
|
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||||
|
compose.onAllNodesWithText(text(R.string.cron_repeat_minutely))[0].performClick()
|
||||||
|
compose.runOnIdle { assertEquals("* * * * *", schedule) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -827,6 +828,7 @@ private fun BlueprintStartFromDropdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun BlueprintSlotField(
|
private fun BlueprintSlotField(
|
||||||
field: CronBlueprintField,
|
field: CronBlueprintField,
|
||||||
@@ -858,37 +860,12 @@ private fun BlueprintSlotField(
|
|||||||
field.type == "time" -> {
|
field.type == "time" -> {
|
||||||
var showTimePicker by remember { mutableStateOf(false) }
|
var showTimePicker by remember { mutableStateOf(false) }
|
||||||
val timeParts = value.split(":").mapNotNull { it.toIntOrNull() }
|
val timeParts = value.split(":").mapNotNull { it.toIntOrNull() }
|
||||||
val curHour = if (timeParts.isNotEmpty()) timeParts[0] else 8
|
val curHour = (timeParts.getOrNull(0) ?: 8).coerceIn(0, 23)
|
||||||
val curMin = if (timeParts.size > 1) timeParts[1] else 0
|
val curMin = (timeParts.getOrNull(1) ?: 0).coerceIn(0, 59)
|
||||||
|
|
||||||
OutlinedTextField(
|
Text(field.label, style = MaterialTheme.typography.labelLarge)
|
||||||
value = value,
|
TimeDisplayCard(hour = curHour, minute = curMin, onClick = { showTimePicker = true })
|
||||||
onValueChange = onValueChange,
|
if (field.help.isNotBlank()) Text(field.help, style = MaterialTheme.typography.bodySmall)
|
||||||
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) {
|
if (showTimePicker) {
|
||||||
val timePickerState =
|
val timePickerState =
|
||||||
@@ -899,10 +876,12 @@ private fun BlueprintSlotField(
|
|||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showTimePicker = false },
|
onDismissRequest = { showTimePicker = false },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
onValueChange(String.format("%02d:%02d", timePickerState.hour, timePickerState.minute))
|
onValueChange(String.format(java.util.Locale.ROOT, "%02d:%02d", timePickerState.hour, timePickerState.minute))
|
||||||
showTimePicker = false
|
showTimePicker = false
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -1018,7 +997,7 @@ private fun DeliverField(
|
|||||||
}
|
}
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SchedulePickerSection(
|
internal fun SchedulePickerSection(
|
||||||
schedule: String,
|
schedule: String,
|
||||||
onScheduleChange: (String) -> Unit,
|
onScheduleChange: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -1031,7 +1010,9 @@ private fun SchedulePickerSection(
|
|||||||
var intervalUnit by remember(schedule) { mutableStateOf(parsed.intervalUnit) }
|
var intervalUnit by remember(schedule) { mutableStateOf(parsed.intervalUnit) }
|
||||||
var selectedDays by remember(schedule) { mutableStateOf(parsed.selectedDays) }
|
var selectedDays by remember(schedule) { mutableStateOf(parsed.selectedDays) }
|
||||||
var dayOfMonth by remember(schedule) { mutableStateOf(parsed.dayOfMonth) }
|
var dayOfMonth by remember(schedule) { mutableStateOf(parsed.dayOfMonth) }
|
||||||
var isAdvanced by remember(schedule) { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
|
var month by remember(schedule) { mutableStateOf(parsed.month) }
|
||||||
|
var isAdvanced by remember { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
|
||||||
|
var repeatExpanded by remember { mutableStateOf(false) }
|
||||||
var showTimePicker by remember { mutableStateOf(false) }
|
var showTimePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
fun emitSchedule(
|
fun emitSchedule(
|
||||||
@@ -1042,19 +1023,13 @@ private fun SchedulePickerSection(
|
|||||||
newIntervalUnit: String = intervalUnit,
|
newIntervalUnit: String = intervalUnit,
|
||||||
newDays: Set<Int> = selectedDays,
|
newDays: Set<Int> = selectedDays,
|
||||||
newDom: Int = dayOfMonth,
|
newDom: Int = dayOfMonth,
|
||||||
|
newMonth: Int = month,
|
||||||
) {
|
) {
|
||||||
val built =
|
onScheduleChange(ParsedSchedule(
|
||||||
when (newMode) {
|
mode = newMode, hour = newHour, minute = newMinute,
|
||||||
ScheduleMode.DAILY -> "$newMinute $newHour * * *"
|
intervalCount = newIntervalCount, intervalUnit = newIntervalUnit,
|
||||||
ScheduleMode.WEEKLY -> {
|
selectedDays = newDays, dayOfMonth = newDom, month = newMonth, raw = schedule,
|
||||||
val daysStr = (if (newDays.isEmpty()) listOf(1) else newDays.sorted()).joinToString(",")
|
).toSchedule())
|
||||||
"$newMinute $newHour * * $daysStr"
|
|
||||||
}
|
|
||||||
ScheduleMode.MONTHLY -> "$newMinute $newHour $newDom * *"
|
|
||||||
ScheduleMode.INTERVAL -> "every $newIntervalCount$newIntervalUnit"
|
|
||||||
ScheduleMode.ADVANCED -> schedule
|
|
||||||
}
|
|
||||||
onScheduleChange(built)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -1079,8 +1054,7 @@ private fun SchedulePickerSection(
|
|||||||
mode = ScheduleMode.ADVANCED
|
mode = ScheduleMode.ADVANCED
|
||||||
} else {
|
} else {
|
||||||
val fallback = parseSchedule(schedule)
|
val fallback = parseSchedule(schedule)
|
||||||
mode = if (fallback.mode == ScheduleMode.ADVANCED) ScheduleMode.DAILY else fallback.mode
|
mode = fallback.mode
|
||||||
emitSchedule(mode)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -1116,45 +1090,46 @@ private fun SchedulePickerSection(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Row(
|
val repeatOptions = listOf(
|
||||||
modifier =
|
ScheduleMode.DAILY to R.string.cron_schedule_mode_daily,
|
||||||
Modifier
|
ScheduleMode.HOURLY to R.string.cron_repeat_hourly,
|
||||||
.fillMaxWidth()
|
ScheduleMode.MINUTELY to R.string.cron_repeat_minutely,
|
||||||
.horizontalScroll(rememberScrollState()),
|
ScheduleMode.WEEKLY to R.string.cron_schedule_mode_weekly,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
ScheduleMode.MONTHLY to R.string.cron_schedule_mode_monthly,
|
||||||
|
ScheduleMode.YEARLY to R.string.cron_repeat_yearly,
|
||||||
|
ScheduleMode.INTERVAL to R.string.cron_schedule_mode_interval,
|
||||||
|
)
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = repeatExpanded,
|
||||||
|
onExpandedChange = { repeatExpanded = it },
|
||||||
) {
|
) {
|
||||||
FilterChip(
|
OutlinedTextField(
|
||||||
selected = mode == ScheduleMode.DAILY,
|
value = stringResource(repeatOptions.firstOrNull { it.first == mode }?.second
|
||||||
onClick = {
|
?: R.string.cron_schedule_advanced_toggle),
|
||||||
mode = ScheduleMode.DAILY
|
onValueChange = {},
|
||||||
emitSchedule(ScheduleMode.DAILY)
|
readOnly = true,
|
||||||
},
|
label = { Text(stringResource(R.string.cron_repeat_label)) },
|
||||||
label = { Text(stringResource(R.string.cron_schedule_mode_daily)) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = repeatExpanded) },
|
||||||
)
|
modifier = Modifier.fillMaxWidth()
|
||||||
FilterChip(
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
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)) },
|
|
||||||
)
|
)
|
||||||
|
ExposedDropdownMenu(expanded = repeatExpanded, onDismissRequest = { repeatExpanded = false }) {
|
||||||
|
repeatOptions.forEach { (option, label) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(label)) },
|
||||||
|
onClick = {
|
||||||
|
mode = option
|
||||||
|
repeatExpanded = false
|
||||||
|
emitSchedule(option)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.cron_repeat_seconds_unavailable)) },
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (mode) {
|
when (mode) {
|
||||||
@@ -1178,6 +1153,7 @@ private fun SchedulePickerSection(
|
|||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -1249,6 +1225,27 @@ private fun SchedulePickerSection(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_repeat_count),
|
||||||
|
options = ((1..120).toList() + intervalCount).distinct().sorted().map { it.toString() },
|
||||||
|
selectedValue = intervalCount.toString(),
|
||||||
|
onOptionSelected = {
|
||||||
|
intervalCount = it.toInt()
|
||||||
|
emitSchedule(mode, newIntervalCount = intervalCount)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val units = listOf("m", "h", "d")
|
||||||
|
val labels = listOf(stringResource(R.string.cron_unit_minute),
|
||||||
|
stringResource(R.string.cron_unit_hour), stringResource(R.string.cron_unit_day))
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_repeat_unit),
|
||||||
|
options = labels,
|
||||||
|
selectedValue = labels[units.indexOf(intervalUnit)],
|
||||||
|
onOptionSelected = {
|
||||||
|
intervalUnit = units[labels.indexOf(it)]
|
||||||
|
emitSchedule(mode, newIntervalUnit = intervalUnit)
|
||||||
|
},
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.cron_schedule_interval_presets),
|
text = stringResource(R.string.cron_schedule_interval_presets),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -1256,6 +1253,7 @@ private fun SchedulePickerSection(
|
|||||||
)
|
)
|
||||||
val presets =
|
val presets =
|
||||||
listOf(
|
listOf(
|
||||||
|
Triple(1, "m", "1 " + stringResource(R.string.cron_unit_minute)),
|
||||||
Triple(15, "m", "15 " + stringResource(R.string.cron_unit_minute)),
|
Triple(15, "m", "15 " + stringResource(R.string.cron_unit_minute)),
|
||||||
Triple(30, "m", "30 " + 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(1, "h", "1 " + stringResource(R.string.cron_unit_hour)),
|
||||||
@@ -1287,7 +1285,20 @@ private fun SchedulePickerSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleMode.MONTHLY -> {
|
ScheduleMode.MONTHLY, ScheduleMode.YEARLY -> {
|
||||||
|
if (mode == ScheduleMode.YEARLY) {
|
||||||
|
val months = java.text.DateFormatSymbols(LocalConfiguration.current.locales[0]).months.take(12)
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_repeat_month),
|
||||||
|
options = months,
|
||||||
|
selectedValue = months[month - 1],
|
||||||
|
onOptionSelected = {
|
||||||
|
month = months.indexOf(it) + 1
|
||||||
|
dayOfMonth = dayOfMonth.coerceAtMost(java.time.Month.of(month).maxLength())
|
||||||
|
emitSchedule(mode, newDom = dayOfMonth, newMonth = month)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
TimeDisplayCard(
|
TimeDisplayCard(
|
||||||
hour = hour,
|
hour = hour,
|
||||||
minute = minute,
|
minute = minute,
|
||||||
@@ -1311,7 +1322,7 @@ private fun SchedulePickerSection(
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (dayOfMonth > 1) {
|
if (dayOfMonth > 1) {
|
||||||
dayOfMonth--
|
dayOfMonth--
|
||||||
emitSchedule(ScheduleMode.MONTHLY, newDom = dayOfMonth)
|
emitSchedule(mode, newDom = dayOfMonth)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = dayOfMonth > 1,
|
enabled = dayOfMonth > 1,
|
||||||
@@ -1325,12 +1336,13 @@ private fun SchedulePickerSection(
|
|||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (dayOfMonth < 31) {
|
val maxDay = if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31
|
||||||
|
if (dayOfMonth < maxDay) {
|
||||||
dayOfMonth++
|
dayOfMonth++
|
||||||
emitSchedule(ScheduleMode.MONTHLY, newDom = dayOfMonth)
|
emitSchedule(mode, newDom = dayOfMonth)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = dayOfMonth < 31,
|
enabled = dayOfMonth < (if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31),
|
||||||
) {
|
) {
|
||||||
Text("+", style = MaterialTheme.typography.titleLarge)
|
Text("+", style = MaterialTheme.typography.titleLarge)
|
||||||
}
|
}
|
||||||
@@ -1338,11 +1350,28 @@ private fun SchedulePickerSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleMode.ADVANCED -> Unit
|
ScheduleMode.HOURLY -> {
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_repeat_minute),
|
||||||
|
options = (0..59).map { it.toString().padStart(2, '0') },
|
||||||
|
selectedValue = minute.toString().padStart(2, '0'),
|
||||||
|
onOptionSelected = { minute = it.toInt(); emitSchedule(mode, newMinute = minute) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ScheduleMode.MINUTELY, ScheduleMode.ADVANCED -> Unit
|
||||||
|
}
|
||||||
|
if ((mode == ScheduleMode.MONTHLY && dayOfMonth > 28) ||
|
||||||
|
(mode == ScheduleMode.YEARLY && month == 2 && dayOfMonth == 29)) {
|
||||||
|
Text(stringResource(R.string.cron_repeat_calendar_note), style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live Summary Card
|
// Live Summary Card
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.cron_schedule_timezone_note),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
colors =
|
||||||
@@ -1366,18 +1395,10 @@ private fun SchedulePickerSection(
|
|||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = CronExpressionFormatter.cronToHumanReadable(schedule.ifBlank { "0 9 * * *" }),
|
text = scheduleSummary(parsed, schedule),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1392,6 +1413,8 @@ private fun SchedulePickerSection(
|
|||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showTimePicker = false },
|
onDismissRequest = { showTimePicker = false },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -1453,7 +1476,7 @@ private fun TimeDisplayCard(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = String.format("%02d:%02d", hour, minute),
|
text = String.format(java.util.Locale.ROOT, "%02d:%02d", hour, minute),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
@@ -1469,121 +1492,29 @@ private fun TimeDisplayCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class ScheduleMode {
|
@Composable
|
||||||
DAILY,
|
private fun scheduleSummary(parsed: ParsedSchedule, raw: String): String {
|
||||||
WEEKLY,
|
val time = java.lang.String.format(java.util.Locale.ROOT, "%02d:%02d", parsed.hour, parsed.minute)
|
||||||
INTERVAL,
|
return when (parsed.mode) {
|
||||||
MONTHLY,
|
ScheduleMode.DAILY -> stringResource(R.string.cron_summary_daily, time)
|
||||||
ADVANCED,
|
ScheduleMode.HOURLY -> stringResource(R.string.cron_summary_hourly, parsed.minute)
|
||||||
}
|
ScheduleMode.MINUTELY -> stringResource(R.string.cron_repeat_minutely)
|
||||||
|
ScheduleMode.WEEKLY -> {
|
||||||
private data class ParsedSchedule(
|
val days = listOf(R.string.cron_dow_sun, R.string.cron_dow_mon, R.string.cron_dow_tue,
|
||||||
val mode: ScheduleMode,
|
R.string.cron_dow_wed, R.string.cron_dow_thu, R.string.cron_dow_fri, R.string.cron_dow_sat)
|
||||||
val hour: Int = 9,
|
val names = parsed.selectedDays.sorted().map { stringResource(days[it]) }.joinToString(", ")
|
||||||
val minute: Int = 0,
|
stringResource(R.string.cron_summary_weekly, names, time)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
ScheduleMode.MONTHLY -> stringResource(R.string.cron_summary_monthly, parsed.dayOfMonth, time)
|
||||||
|
ScheduleMode.YEARLY -> stringResource(R.string.cron_summary_yearly, parsed.dayOfMonth, parsed.month, time)
|
||||||
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
ScheduleMode.INTERVAL -> {
|
||||||
}
|
val unit = stringResource(when (parsed.intervalUnit) {
|
||||||
|
"m" -> R.string.cron_unit_minute
|
||||||
private fun parseDowList(dow: String): List<Int> {
|
"h" -> R.string.cron_unit_hour
|
||||||
if (dow.contains("-")) {
|
else -> R.string.cron_unit_day
|
||||||
val bounds = dow.split("-")
|
})
|
||||||
if (bounds.size == 2) {
|
stringResource(R.string.cron_summary_interval, parsed.intervalCount, unit)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ScheduleMode.ADVANCED -> raw
|
||||||
}
|
}
|
||||||
return dow.split(",")
|
|
||||||
.mapNotNull { it.trim().toIntOrNull() }
|
|
||||||
.filter { it in 0..7 }
|
|
||||||
.map { if (it == 7) 0 else it }
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
|
internal enum class ScheduleMode {
|
||||||
|
HOURLY,
|
||||||
|
MINUTELY,
|
||||||
|
YEARLY,
|
||||||
|
DAILY,
|
||||||
|
WEEKLY,
|
||||||
|
INTERVAL,
|
||||||
|
MONTHLY,
|
||||||
|
ADVANCED,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal 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 month: Int = 1,
|
||||||
|
val raw: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal 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()
|
||||||
|
?: return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
if (count <= 0) return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
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) {
|
||||||
|
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hourStepMatch = Regex("""^0\s+\*/(\d+)\s+\*\s+\*\s+\*$""").find(clean)
|
||||||
|
if (hourStepMatch != null) {
|
||||||
|
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parts = clean.split(" ")
|
||||||
|
if (clean == "* * * * *") return ParsedSchedule(mode = ScheduleMode.MINUTELY)
|
||||||
|
if (parts.size == 5 && parts.drop(1).all { it == "*" }) {
|
||||||
|
val minute = parts[0].toIntOrNull()
|
||||||
|
if (minute != null && minute in 0..59) return ParsedSchedule(mode = ScheduleMode.HOURLY, minute = minute)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
val monthNumber = month.toIntOrNull()
|
||||||
|
val dayNumber = dom.toIntOrNull()
|
||||||
|
if (monthNumber != null && monthNumber in 1..12 && dayNumber != null &&
|
||||||
|
dayNumber in 1..java.time.Month.of(monthNumber).maxLength() && dow == "*") {
|
||||||
|
return ParsedSchedule(ScheduleMode.YEARLY, hour = hour, minute = minute,
|
||||||
|
dayOfMonth = dayNumber, month = monthNumber)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ParsedSchedule.toSchedule(): String {
|
||||||
|
require(hour in 0..23 && minute in 0..59)
|
||||||
|
require(dayOfMonth in 1..31 && month in 1..12)
|
||||||
|
return when (mode) {
|
||||||
|
ScheduleMode.DAILY -> "$minute $hour * * *"
|
||||||
|
ScheduleMode.HOURLY -> "$minute * * * *"
|
||||||
|
ScheduleMode.MINUTELY -> "* * * * *"
|
||||||
|
ScheduleMode.WEEKLY -> {
|
||||||
|
require(selectedDays.isNotEmpty() && selectedDays.all { it in 0..6 })
|
||||||
|
"$minute $hour * * ${selectedDays.sorted().joinToString(",")}"
|
||||||
|
}
|
||||||
|
ScheduleMode.MONTHLY -> "$minute $hour $dayOfMonth * *"
|
||||||
|
ScheduleMode.YEARLY -> {
|
||||||
|
require(dayOfMonth <= java.time.Month.of(month).maxLength())
|
||||||
|
"$minute $hour $dayOfMonth $month *"
|
||||||
|
}
|
||||||
|
ScheduleMode.INTERVAL -> {
|
||||||
|
require(intervalCount > 0 && intervalUnit in listOf("m", "h", "d"))
|
||||||
|
"every $intervalCount$intervalUnit"
|
||||||
|
}
|
||||||
|
ScheduleMode.ADVANCED -> raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val values = dow.split(",").map { it.trim().toIntOrNull() ?: return emptyList() }
|
||||||
|
if (values.any { it !in 0..7 }) return emptyList()
|
||||||
|
return values
|
||||||
|
.map { if (it == 7) 0 else it }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
@@ -1292,9 +1292,9 @@
|
|||||||
<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_interval">Aralık</string>
|
||||||
<string name="cron_schedule_mode_daily">Her Gün</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_weekly">Her hafta</string>
|
||||||
<string name="cron_schedule_mode_monthly">Aylık</string>
|
<string name="cron_schedule_mode_monthly">Her ay</string>
|
||||||
<string name="cron_schedule_mode_advanced">Gelişmiş</string>
|
<string name="cron_schedule_mode_advanced">Gelişmiş</string>
|
||||||
<string name="cron_schedule_select_time">Çalışma Saati</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_advanced_toggle">Cron ifadesini elle düzenle</string>
|
||||||
@@ -1934,4 +1934,21 @@
|
|||||||
<string name="aiturk_feature_unavailable_hint">Bilgisayardaki AITURK IDE\'den kullanın ya da AITURK IDE ve AITURK Mobil\'i güncelleyin.</string>
|
<string name="aiturk_feature_unavailable_hint">Bilgisayardaki AITURK IDE\'den kullanın ya da AITURK IDE ve AITURK Mobil\'i güncelleyin.</string>
|
||||||
<string name="aiturk_phone_forbidden">Güvenlik nedeniyle bu işlem telefondan yapılamaz. Bilgisayardaki AITURK IDE\'den yapın.</string>
|
<string name="aiturk_phone_forbidden">Güvenlik nedeniyle bu işlem telefondan yapılamaz. Bilgisayardaki AITURK IDE\'den yapın.</string>
|
||||||
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
|
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
|
||||||
|
<string name="cron_repeat_label">Tekrar</string>
|
||||||
|
<string name="cron_repeat_hourly">Her saat</string>
|
||||||
|
<string name="cron_repeat_minutely">Her dakika</string>
|
||||||
|
<string name="cron_repeat_yearly">Her yıl</string>
|
||||||
|
<string name="cron_repeat_seconds_unavailable">Her saniye · sunucu desteklemiyor</string>
|
||||||
|
<string name="cron_repeat_month">Ay</string>
|
||||||
|
<string name="cron_repeat_minute">Saatin kaçıncı dakikasında?</string>
|
||||||
|
<string name="cron_summary_daily">Her gün %1$s</string>
|
||||||
|
<string name="cron_summary_hourly">Her saat, %1$02d. dakikada</string>
|
||||||
|
<string name="cron_summary_weekly">%1$s · %2$s</string>
|
||||||
|
<string name="cron_summary_monthly">Her ayın %1$d. günü · %2$s</string>
|
||||||
|
<string name="cron_summary_yearly">Her yıl %1$d/%2$d · %3$s</string>
|
||||||
|
<string name="cron_summary_interval">Her %1$d %2$s</string>
|
||||||
|
<string name="cron_repeat_calendar_note">Seçilen günün olmadığı ay veya yıllarda çalışmaz.</string>
|
||||||
|
<string name="cron_repeat_count">Aralık</string>
|
||||||
|
<string name="cron_repeat_unit">Birim</string>
|
||||||
|
<string name="cron_schedule_timezone_note">Saatler bağlı AITURK sunucusunda ayarlı saat dilimine göredir.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1934,4 +1934,21 @@
|
|||||||
<string name="aiturk_feature_unavailable_hint">Use it in AITURK IDE on your computer, or update AITURK IDE and AITURK Mobil.</string>
|
<string name="aiturk_feature_unavailable_hint">Use it in AITURK IDE on your computer, or update AITURK IDE and AITURK Mobil.</string>
|
||||||
<string name="aiturk_phone_forbidden">For security this cannot be done from the phone. Do it in AITURK IDE on your computer.</string>
|
<string name="aiturk_phone_forbidden">For security this cannot be done from the phone. Do it in AITURK IDE on your computer.</string>
|
||||||
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
|
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
|
||||||
|
<string name="cron_repeat_label">Repeat</string>
|
||||||
|
<string name="cron_repeat_hourly">Every hour</string>
|
||||||
|
<string name="cron_repeat_minutely">Every minute</string>
|
||||||
|
<string name="cron_repeat_yearly">Every year</string>
|
||||||
|
<string name="cron_repeat_seconds_unavailable">Every second · unsupported by server</string>
|
||||||
|
<string name="cron_repeat_month">Month</string>
|
||||||
|
<string name="cron_repeat_minute">At which minute of the hour?</string>
|
||||||
|
<string name="cron_summary_daily">Every day at %1$s</string>
|
||||||
|
<string name="cron_summary_hourly">Every hour at minute %1$02d</string>
|
||||||
|
<string name="cron_summary_weekly">%1$s · %2$s</string>
|
||||||
|
<string name="cron_summary_monthly">Day %1$d of each month · %2$s</string>
|
||||||
|
<string name="cron_summary_yearly">Every year on %1$d/%2$d · %3$s</string>
|
||||||
|
<string name="cron_summary_interval">Every %1$d %2$s</string>
|
||||||
|
<string name="cron_repeat_calendar_note">Skips months or years without the selected date.</string>
|
||||||
|
<string name="cron_repeat_count">Interval</string>
|
||||||
|
<string name="cron_repeat_unit">Unit</string>
|
||||||
|
<string name="cron_schedule_timezone_note">Times use the time zone configured on the connected AITURK server.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ScheduleSelectionTest {
|
||||||
|
@Test
|
||||||
|
fun `calendar selections preserve exact time when reopened`() {
|
||||||
|
val cases = mapOf(
|
||||||
|
"59 23 * * *" to ScheduleMode.DAILY,
|
||||||
|
"59 * * * *" to ScheduleMode.HOURLY,
|
||||||
|
"* * * * *" to ScheduleMode.MINUTELY,
|
||||||
|
"59 23 * * 1,3,5" to ScheduleMode.WEEKLY,
|
||||||
|
"59 23 31 * *" to ScheduleMode.MONTHLY,
|
||||||
|
"59 23 29 2 *" to ScheduleMode.YEARLY,
|
||||||
|
"0 0 1 1 *" to ScheduleMode.YEARLY,
|
||||||
|
"every 120m" to ScheduleMode.INTERVAL,
|
||||||
|
)
|
||||||
|
cases.forEach { (expression, mode) ->
|
||||||
|
val parsed = parseSchedule(expression)
|
||||||
|
assertEquals(expression, mode, parsed.mode)
|
||||||
|
assertEquals(expression, expression, parsed.toSchedule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown and malformed schedules remain untouched`() {
|
||||||
|
listOf("every 1s", "every 0m", "every 999999999999999h", "0 9 * * 1,garbage",
|
||||||
|
"0 9 * * 1,8", "0 9 31 2 *", "99 99 * * *", "2030-01-01T12:00:00",
|
||||||
|
"*/45 * * * *", "0 */5 * * *", "0 9 * * MON-FRI").forEach { expression ->
|
||||||
|
val parsed = parseSchedule(expression)
|
||||||
|
assertEquals(expression, ScheduleMode.ADVANCED, parsed.mode)
|
||||||
|
assertEquals(expression, expression, parsed.toSchedule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `daily time selection builds actual backend expression`() {
|
||||||
|
assertEquals("59 23 * * *", ParsedSchedule(ScheduleMode.DAILY, hour = 23, minute = 59).toSchedule())
|
||||||
|
assertEquals("every 90m", ParsedSchedule(ScheduleMode.INTERVAL, intervalCount = 90, intervalUnit = "m").toSchedule())
|
||||||
|
assertEquals(setOf(0, 6), parseSchedule("0 9 * * 6-7").selectedDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user