Let a new event's length be a setting, per calendar (#54)
This commit is contained in:
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- A new event no longer always lasts an hour. **Settings → New event form →
|
||||
Default duration** sets how long one opens, and each calendar may keep its own
|
||||
length underneath — 8 hours for the calendar you keep work shifts in, 30
|
||||
minutes for the one you book calls in. A calendar without its own length
|
||||
follows the default, switching calendars mid-form re-stretches the event, and
|
||||
setting an end time by hand keeps it. All-day events are unaffected ([#54]).
|
||||
|
||||
## [2.17.1] — 2026-07-30
|
||||
|
||||
### Added
|
||||
@@ -1253,3 +1261,4 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
|
||||
|
||||
@@ -457,6 +457,46 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a new **timed** event lasts, in minutes (#54). Defaults to
|
||||
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
|
||||
* overrides in [perCalendarEventDuration] take precedence; all-day events are
|
||||
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
|
||||
*/
|
||||
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
|
||||
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
|
||||
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
|
||||
store.edit {
|
||||
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
|
||||
* id: a calendar **present** in the map gives its new events that length, one
|
||||
* **absent** inherits the global default (there is no "no duration", so this
|
||||
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
|
||||
*/
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
|
||||
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
|
||||
}
|
||||
|
||||
/** [minutes] null drops the override, so the calendar inherits the global default. */
|
||||
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||
store.edit { prefs ->
|
||||
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
|
||||
if (minutes == null) {
|
||||
current.remove(calendarId)
|
||||
} else {
|
||||
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||
* for users whose only calendar app this is, reminders are essential; the
|
||||
@@ -814,6 +854,15 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
|
||||
internal val CALENDAR_EVENT_DURATION_KEY =
|
||||
stringPreferencesKey("per_calendar_event_duration")
|
||||
|
||||
/** A new timed event's length until the user changes it: one hour. */
|
||||
const val DEFAULT_EVENT_DURATION = 60
|
||||
internal const val MIN_EVENT_DURATION = 1
|
||||
/** A day — past that the form is describing a multi-day event, not a default. */
|
||||
internal const val MAX_EVENT_DURATION = 1_440
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
@@ -905,6 +954,17 @@ fun resolveDefaultReminder(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The length a new timed event on [calendarId] opens with: that calendar's
|
||||
* override if it has one, otherwise the [global] default. Pure so it can be
|
||||
* unit-tested.
|
||||
*/
|
||||
fun resolveDefaultEventDuration(
|
||||
global: Int,
|
||||
overrides: Map<Long, Int>,
|
||||
calendarId: Long?,
|
||||
): Int = calendarId?.let { overrides[it] } ?: global
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
|
||||
@@ -952,6 +1012,27 @@ private fun String?.toReminderList(): List<Int> = when {
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
/**
|
||||
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
|
||||
* Malformed entries and out-of-range lengths are dropped, so a garbled value
|
||||
* degrades to "inherits the global default" instead of throwing.
|
||||
*/
|
||||
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
|
||||
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP)
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val minutes = parts[1].trim().toIntOrNull()
|
||||
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
|
||||
?: return@mapNotNull null
|
||||
calendarId to minutes
|
||||
}.toMap()
|
||||
|
||||
private fun Map<Long, Int>.toStoredDurations(): String =
|
||||
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
|
||||
"$id$KEY_VALUE_SEP$minutes"
|
||||
}
|
||||
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -354,35 +354,63 @@ private fun CustomDaysEditor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||
* is reused for both the rows and the custom preview.
|
||||
* Duration picker, full-screen and **single-select**: the [presets] (whole-minute
|
||||
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
|
||||
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
|
||||
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
|
||||
* confirming a custom value applies via [onSelect] and closes. [label] renders a
|
||||
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
|
||||
* the custom preview.
|
||||
*
|
||||
* [inheritLabel] adds an exclusive "use the default" row on top, for the
|
||||
* per-calendar pickers; picking it reports null. [selected] is null exactly when
|
||||
* that row is the current choice. [description] explains the setting above the
|
||||
* rows when it needs it.
|
||||
*/
|
||||
@Composable
|
||||
fun SnoozeDurationPicker(
|
||||
fun DurationPicker(
|
||||
title: String,
|
||||
presets: List<Int>,
|
||||
selected: Int,
|
||||
selected: Int?,
|
||||
label: @Composable (Int) -> String,
|
||||
onSelect: (Int) -> Unit,
|
||||
onSelect: (Int?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
description: String? = null,
|
||||
inheritLabel: String? = null,
|
||||
) {
|
||||
val customSelected = selected !in presets
|
||||
val inherits = selected == null
|
||||
// The current choice when it isn't one of the presets — the Custom row then
|
||||
// names it and the editor opens pre-filled with it.
|
||||
val custom = selected?.takeIf { it !in presets }
|
||||
val rowCount = presets.size + 1 // + the custom row
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
|
||||
}
|
||||
var unit by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: ReminderUnit.Minutes)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
if (description != null) PickerDescription(description)
|
||||
if (inheritLabel != null) {
|
||||
GroupedRow(
|
||||
title = inheritLabel,
|
||||
position = Position.Alone,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(null)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
presets.forEachIndexed { index, minute ->
|
||||
val isSelected = minute == selected
|
||||
GroupedRow(
|
||||
@@ -403,10 +431,10 @@ fun SnoozeDurationPicker(
|
||||
// The Custom row connects downward into the editor card when expanded, so
|
||||
// the two read as one grouped container (the shared custom-expand pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
selected = custom != null,
|
||||
trailing = if (custom != null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
@@ -418,7 +446,7 @@ fun SnoozeDurationPicker(
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
CustomSnoozeEditor(
|
||||
CustomDurationEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
@@ -433,21 +461,21 @@ fun SnoozeDurationPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole hours if the delay divides evenly, else minutes. */
|
||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||
/** Whole hours if the length divides evenly, else minutes. */
|
||||
private fun durationCustomUnit(minutes: Int): ReminderUnit =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||
|
||||
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||
private fun durationCustomAmount(minutes: Int): Int =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||
|
||||
/**
|
||||
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||
* The expanded "Custom" duration editor: a tonal card connected to the Custom row
|
||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||
* amount. [onConfirm] receives the final delay in minutes.
|
||||
* the length it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||
* amount. [onConfirm] receives the final length in minutes.
|
||||
*/
|
||||
@Composable
|
||||
private fun CustomSnoozeEditor(
|
||||
private fun CustomDurationEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
unit: ReminderUnit,
|
||||
|
||||
@@ -37,3 +37,21 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanise a plain duration — no "before": "45 minutes", "8 hours", or both
|
||||
* parts for a mixed length ("1 hour 30 minutes"). Shared by the snooze delay and
|
||||
* the default event duration (#54).
|
||||
*/
|
||||
@Composable
|
||||
fun durationLabel(minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
val rest = minutes % 60
|
||||
val hoursLabel = pluralStringResource(R.plurals.duration_hours, hours, hours)
|
||||
val minutesLabel = pluralStringResource(R.plurals.duration_minutes, rest, rest)
|
||||
return when {
|
||||
hours == 0 -> minutesLabel
|
||||
rest == 0 -> hoursLabel
|
||||
else -> stringResource(R.string.duration_hours_minutes, hoursLabel, minutesLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
@@ -51,7 +52,7 @@ import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -113,6 +114,9 @@ class EventEditViewModel @Inject constructor(
|
||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||
// their choice. Reset with the form.
|
||||
private val _remindersTouched = MutableStateFlow(false)
|
||||
// Same freeze for the default duration (#54): once the user has set an end
|
||||
// time by hand, switching calendars no longer stretches the event.
|
||||
private val _durationTouched = MutableStateFlow(false)
|
||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||
@@ -282,8 +286,10 @@ class EventEditViewModel @Inject constructor(
|
||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||
* in the day/week grid; without it the default is the next full hour (today)
|
||||
* or 09:00 (any other day). No-op when a form is already open, so user input
|
||||
* survives configuration changes; [reset] clears it when the screen closes.
|
||||
* or 09:00 (any other day). The event opens an hour long and is stretched to
|
||||
* the configured default by [applyDefaultDuration], like the reminders.
|
||||
* No-op when a form is already open, so user input survives configuration
|
||||
* changes; [reset] clears it when the screen closes.
|
||||
*/
|
||||
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||
if (_form.value != null) return
|
||||
@@ -300,9 +306,11 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||
}
|
||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
||||
.toLocalDateTime(zone)
|
||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||
applyDefaultReminder()
|
||||
applyDefaultDuration()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,7 +366,9 @@ class EventEditViewModel @Inject constructor(
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||
_form.value = form.copy(reminders = reminders)
|
||||
// Write through update(): the duration default resolves in parallel,
|
||||
// so both must compose onto the current form, not a pre-suspend copy.
|
||||
update { it.copy(reminders = reminders) }
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
if (reminders.isNotEmpty()) {
|
||||
@@ -377,6 +387,33 @@ class EventEditViewModel @Inject constructor(
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||
}.first()
|
||||
|
||||
/**
|
||||
* Stretch a new timed event to the configured default length (#54) — the
|
||||
* resolved calendar's per-calendar duration, otherwise the global default —
|
||||
* keeping its start put. No-op while editing an existing event, on an all-day
|
||||
* event (which is date-anchored), or once the user has set an end time by
|
||||
* hand. [calendarId] short-circuits the resolution after a calendar switch;
|
||||
* null resolves it as the form does.
|
||||
*/
|
||||
private fun applyDefaultDuration(calendarId: Long? = null) {
|
||||
if (_editTarget.value != null || _durationTouched.value) return
|
||||
viewModelScope.launch {
|
||||
val global = settingsPrefs.defaultEventDurationMinutes.first()
|
||||
val overrides = settingsPrefs.perCalendarEventDuration.first()
|
||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||
// Re-check after suspending: bail if the form closed or the user edited.
|
||||
if (_editTarget.value != null || _durationTouched.value) return@launch
|
||||
if (_form.value?.isAllDay != false) return@launch
|
||||
val duration = resolveDefaultEventDuration(global, overrides, targetId)
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
update { form ->
|
||||
form.copy(
|
||||
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||
* file often has none while the user still expects their configured default.
|
||||
@@ -455,6 +492,7 @@ class EventEditViewModel @Inject constructor(
|
||||
_editTarget.value = null
|
||||
_loadFailed.value = false
|
||||
_remindersTouched.value = false
|
||||
_durationTouched.value = false
|
||||
_importReminderPrompt.value = null
|
||||
}
|
||||
|
||||
@@ -479,6 +517,9 @@ class EventEditViewModel @Inject constructor(
|
||||
// The default reminder differs for all-day vs timed; re-apply the
|
||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||
applyDefaultReminder()
|
||||
// Coming back out of all-day re-applies the default length (guarded);
|
||||
// going all-day is a no-op, since dates carry no duration.
|
||||
applyDefaultDuration()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,9 +541,10 @@ class EventEditViewModel @Inject constructor(
|
||||
*/
|
||||
fun setCalendar(id: Long) {
|
||||
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||
// A fresh event re-inherits the new calendar's default reminder unless
|
||||
// the user has already hand-edited it (guarded inside).
|
||||
// A fresh event re-inherits the new calendar's default reminder and
|
||||
// length unless the user has already hand-edited them (guarded inside).
|
||||
applyDefaultReminder(id)
|
||||
applyDefaultDuration(id)
|
||||
}
|
||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||
@@ -559,8 +601,18 @@ class EventEditViewModel @Inject constructor(
|
||||
/** Moving the start drags the end along, preserving the duration. */
|
||||
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||
|
||||
// Setting an end by hand is the user picking a length: it freezes the
|
||||
// default duration, so a later calendar switch keeps their span (#54).
|
||||
fun setEndDate(date: LocalDate) {
|
||||
_durationTouched.value = true
|
||||
update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||
}
|
||||
|
||||
fun setEndTime(time: LocalTime) {
|
||||
_durationTouched.value = true
|
||||
update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and write. Saving a dirty recurring event pauses in
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
|
||||
/** New event form: which fields it opens with, and how it behaves. */
|
||||
@Composable
|
||||
@@ -27,6 +41,13 @@ internal fun EventFormScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||
// The calendar whose duration-override picker is open, if any.
|
||||
var durationTarget by remember { mutableStateOf<Long?>(null) }
|
||||
var durationSectionExpanded by remember { mutableStateOf(false) }
|
||||
// Special-dates calendars carry all-day events only, which have no length.
|
||||
val durationCalendars = state.writableCalendars.filterNot { it.id in state.managedCalendarIds }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_event_form),
|
||||
onBack = onBack,
|
||||
@@ -80,6 +101,64 @@ internal fun EventFormScreen(
|
||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||
)
|
||||
|
||||
// How long a new timed event opens (#54), globally and per calendar —
|
||||
// the per-calendar list folds behind its own header, like the reminders.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_event_duration),
|
||||
summary = durationLabel(state.defaultEventDurationMinutes),
|
||||
position = if (durationCalendars.isEmpty()) Position.Alone else Position.Top,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Schedule,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { showDefaultDuration = true },
|
||||
)
|
||||
if (durationCalendars.isNotEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_calendar_durations_title),
|
||||
summary = stringResource(R.string.settings_calendar_durations_hint),
|
||||
// Expanded, the header opens the calendar run below it and the
|
||||
// two read as one container (the shared expand pattern).
|
||||
position = if (durationSectionExpanded) Position.Top else Position.Bottom,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (durationSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { durationSectionExpanded = !durationSectionExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = durationSectionExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
durationCalendars.forEachIndexed { index, calendar ->
|
||||
val override = state.perCalendarEventDuration[calendar.id]
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = override?.let { durationLabel(it) }
|
||||
?: stringResource(
|
||||
R.string.settings_calendar_duration_inherits,
|
||||
durationLabel(state.defaultEventDurationMinutes),
|
||||
),
|
||||
// The header above is the run's first row, so the
|
||||
// calendars continue it: middles, then its bottom.
|
||||
position = positionOf(index + 1, durationCalendars.size + 1),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
onClick = { durationTarget = calendar.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event colour on calendars that publish no colour set (some
|
||||
// CalDAV); off by default, since it may not survive their next sync.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
@@ -100,4 +179,34 @@ internal fun EventFormScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showDefaultDuration) {
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_event_duration),
|
||||
description = stringResource(R.string.settings_event_duration_hint),
|
||||
presets = EVENT_DURATION_PRESETS,
|
||||
selected = state.defaultEventDurationMinutes,
|
||||
label = { durationLabel(it) },
|
||||
// No inherit row on the global default, so a pick is never null.
|
||||
onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } },
|
||||
onDismiss = { showDefaultDuration = false },
|
||||
)
|
||||
}
|
||||
durationTarget?.let { calendarId ->
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_event_duration),
|
||||
presets = EVENT_DURATION_PRESETS,
|
||||
selected = state.perCalendarEventDuration[calendarId],
|
||||
label = { durationLabel(it) },
|
||||
inheritLabel = stringResource(
|
||||
R.string.settings_calendar_duration_use_default,
|
||||
durationLabel(state.defaultEventDurationMinutes),
|
||||
),
|
||||
onSelect = { viewModel.setCalendarEventDuration(calendarId, it) },
|
||||
onDismiss = { durationTarget = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Lengths offered for a new timed event, in minutes — up to a full work day. */
|
||||
private val EVENT_DURATION_PRESETS = listOf(15, 30, 45, 60, 90, 120, 240, 480)
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -38,10 +37,11 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -146,7 +146,7 @@ internal fun NotificationsScreen(
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||
summary = durationLabel(state.snoozeMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
@@ -245,12 +245,13 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
|
||||
if (showSnooze) {
|
||||
SnoozeDurationPicker(
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
presets = SNOOZE_PRESETS,
|
||||
selected = state.snoozeMinutes,
|
||||
label = { snoozeDurationLabel(it) },
|
||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||
label = { durationLabel(it) },
|
||||
// No inherit row here, so a pick is never null.
|
||||
onSelect = { minutes -> minutes?.let { viewModel.setSnoozeMinutes(it) } },
|
||||
onDismiss = { showSnooze = false },
|
||||
)
|
||||
}
|
||||
@@ -348,15 +349,6 @@ private fun calendarOverrideSummary(
|
||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
|
||||
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
||||
@Composable
|
||||
private fun snoozeDurationLabel(minutes: Int): String =
|
||||
if (minutes % 60 == 0) {
|
||||
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||
|
||||
@@ -63,6 +63,10 @@ data class SettingsUiState(
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||
val autofocusEventTitle: Boolean = true,
|
||||
/** How long a new timed event lasts, in minutes (#54); all-day events ignore it. */
|
||||
val defaultEventDurationMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION,
|
||||
/** Per-calendar overrides of [defaultEventDurationMinutes]; absent = inherit. */
|
||||
val perCalendarEventDuration: Map<Long, Int> = emptyMap(),
|
||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||
val remindersEnabled: Boolean = true,
|
||||
/**
|
||||
|
||||
@@ -123,8 +123,14 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
writableCalendars,
|
||||
prefs.managedCalendarIds,
|
||||
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||
// The new-event duration defaults (#54) fold into one flow so they
|
||||
// fit this group — the outer combine is at its five-arg limit.
|
||||
combine(
|
||||
prefs.defaultEventDurationMinutes,
|
||||
prefs.perCalendarEventDuration,
|
||||
) { duration, perCalendar -> EventDurations(duration, perCalendar) },
|
||||
) { overrides, allDayOverrides, calendars, managedIds, durations ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds, durations)
|
||||
},
|
||||
combine(
|
||||
prefs.defaultView,
|
||||
@@ -198,6 +204,8 @@ class SettingsViewModel @Inject constructor(
|
||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||
writableCalendars = overrides.calendars,
|
||||
managedCalendarIds = overrides.managedIds,
|
||||
defaultEventDurationMinutes = overrides.durations.default,
|
||||
perCalendarEventDuration = overrides.durations.perCalendar,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -260,6 +268,12 @@ class SettingsViewModel @Inject constructor(
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
val managedIds: Set<Long>,
|
||||
val durations: EventDurations,
|
||||
)
|
||||
|
||||
private data class EventDurations(
|
||||
val default: Int,
|
||||
val perCalendar: Map<Long, Int>,
|
||||
)
|
||||
|
||||
private data class ViewSettings(
|
||||
@@ -600,6 +614,15 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultEventDuration(minutes: Int) {
|
||||
viewModelScope.launch { prefs.setDefaultEventDurationMinutes(minutes) }
|
||||
}
|
||||
|
||||
/** [minutes] null clears the override, so the calendar inherits the default. */
|
||||
fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||
viewModelScope.launch { prefs.setCalendarEventDuration(calendarId, minutes) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
@@ -242,6 +242,8 @@
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<!-- A mixed duration: %1$s is the hours part, %2$s the minutes part. -->
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d week</item>
|
||||
<item quantity="other">%d weeks</item>
|
||||
@@ -428,6 +430,14 @@
|
||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
||||
<string name="settings_event_duration">Default duration</string>
|
||||
<string name="settings_event_duration_hint">How long a new event lasts until you change its end time. All-day events aren\'t affected.</string>
|
||||
<string name="settings_calendar_durations_title">Per-calendar duration</string>
|
||||
<string name="settings_calendar_durations_hint">Give a calendar its own default length — e.g. 8 hours for work shifts.</string>
|
||||
<!-- Row summary for a calendar with no duration of its own. %1$s is the default length, e.g. "1 hour". -->
|
||||
<string name="settings_calendar_duration_inherits">Default (%1$s)</string>
|
||||
<!-- Picker row that drops a calendar\'s own length. %1$s is the default length. -->
|
||||
<string name="settings_calendar_duration_use_default">Use default duration (%1$s)</string>
|
||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
|
||||
@@ -475,6 +475,56 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event duration defaults to an hour and clamps to a sane span`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(60)
|
||||
prefs.setDefaultEventDurationMinutes(480)
|
||||
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(480)
|
||||
prefs.setDefaultEventDurationMinutes(0)
|
||||
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(1)
|
||||
prefs.setDefaultEventDurationMinutes(5_000)
|
||||
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar duration round-trips and clears back to inherit`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.perCalendarEventDuration.first()).isEmpty()
|
||||
prefs.setCalendarEventDuration(7L, 480)
|
||||
prefs.setCalendarEventDuration(9L, 30)
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(7L, 480, 9L, 30)
|
||||
prefs.setCalendarEventDuration(7L, null)
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar duration entries are dropped, the rest survive`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
// A bad id, a non-numeric length, one out of range, and a good entry.
|
||||
m[SettingsPrefs.CALENDAR_EVENT_DURATION_KEY] = "x=60;7=soon;8=99999;9=45"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 45)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveDefaultEventDuration prefers the calendar's own length`() {
|
||||
val overrides = mapOf(7L to 480)
|
||||
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = 7L)).isEqualTo(480)
|
||||
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = 9L)).isEqualTo(60)
|
||||
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = null)).isEqualTo(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
|
||||
@@ -22,6 +22,9 @@ import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -85,9 +88,11 @@ class EventEditViewModelTest {
|
||||
private fun viewModel(
|
||||
tempDir: Path,
|
||||
fake: FakeCalendarDataSource,
|
||||
// Passed in by the tests that need to seed a setting first; a second
|
||||
// DataStore on the same file would clash, so it is built only once.
|
||||
s: SettingsPrefs = settings(tempDir),
|
||||
): EventEditViewModel {
|
||||
val p = prefs(tempDir)
|
||||
val s = settings(tempDir)
|
||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||
return EventEditViewModel(repo, p, s, dispatcher)
|
||||
}
|
||||
@@ -200,6 +205,71 @@ class EventEditViewModelTest {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new event takes its calendar's default duration, and follows a switch`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultEventDurationMinutes(30)
|
||||
s.setCalendarEventDuration(2L, 480)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Calendar 1 has no length of its own, so it inherits the global 30 min.
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an end time set by hand survives a calendar switch`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setCalendarEventDuration(2L, 480)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
|
||||
advanceUntilIdle()
|
||||
vm.setEndTime(LocalTime(10, 15))
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event ignores the default duration`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setCalendarEventDuration(2L, 480)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
|
||||
advanceUntilIdle()
|
||||
vm.setAllDay(true)
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Dates carry no length: the times stay where the form put them.
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing a recurring event without moving still asks for the scope`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
Reference in New Issue
Block a user