Let a new event's length be a setting, per calendar (#54)

This commit is contained in:
2026-07-31 15:24:03 +02:00
parent 0097a9c534
commit 0f14fd2bfd
12 changed files with 497 additions and 51 deletions

View File

@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [2.17.1] — 2026-07-30
### Added ### Added
@@ -1253,3 +1261,4 @@ automatically, with zero telemetry and no internet permission.
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89 [#89]: https://codeberg.org/jlmakiola/calendula/issues/89
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103 [#103]: https://codeberg.org/jlmakiola/calendula/issues/103
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69 [#69]: https://codeberg.org/jlmakiola/calendula/issues/69
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54

View File

@@ -457,6 +457,46 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled } 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 — * Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
* for users whose only calendar app this is, reminders are essential; the * 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 DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title") 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 REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY = 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]. */ /** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
private const val WEEK_START_AUTO = "AUTO" private const val WEEK_START_AUTO = "AUTO"
@@ -952,6 +1012,27 @@ private fun String?.toReminderList(): List<Int> = when {
private fun List<Int>.toStoredReminders(): String = private fun List<Int>.toStoredReminders(): String =
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() } 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 = private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -354,35 +354,63 @@ private fun CustomDaysEditor(
} }
/** /**
* Snooze-duration picker, full-screen and **single-select**: the [presets] * Duration picker, full-screen and **single-select**: the [presets] (whole-minute
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that * lengths) each sit as a checkmark row, with a "Custom" row that expands an
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an * inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking * one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
* a preset or confirming a custom value applies via [onSelect] and closes. * confirming a custom value applies via [onSelect] and closes. [label] renders a
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and * length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
* is reused for both the rows and the custom preview. * 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 @Composable
fun SnoozeDurationPicker( fun DurationPicker(
title: String, title: String,
presets: List<Int>, presets: List<Int>,
selected: Int, selected: Int?,
label: @Composable (Int) -> String, label: @Composable (Int) -> String,
onSelect: (Int) -> Unit, onSelect: (Int?) -> Unit,
onDismiss: () -> 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 val rowCount = presets.size + 1 // + the custom row
var customExpanded by rememberSaveable { mutableStateOf(false) } var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { var amountText by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "") mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
} }
var unit by rememberSaveable { 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) { 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 -> presets.forEachIndexed { index, minute ->
val isSelected = minute == selected val isSelected = minute == selected
GroupedRow( GroupedRow(
@@ -403,10 +431,10 @@ fun SnoozeDurationPicker(
// The Custom row connects downward into the editor card when expanded, so // The Custom row connects downward into the editor card when expanded, so
// the two read as one grouped container (the shared custom-expand pattern). // the two read as one grouped container (the shared custom-expand pattern).
GroupedRow( 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), position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
selected = customSelected, selected = custom != null,
trailing = if (customSelected) { trailing = if (custom != null) {
{ SelectedCheck() } { SelectedCheck() }
} else { } else {
null null
@@ -418,7 +446,7 @@ fun SnoozeDurationPicker(
enter = expandEnter(), enter = expandEnter(),
exit = collapseExit(), exit = collapseExit(),
) { ) {
CustomSnoozeEditor( CustomDurationEditor(
amountText = amountText, amountText = amountText,
onAmountChange = { amountText = it }, onAmountChange = { amountText = it },
unit = unit, unit = unit,
@@ -433,21 +461,21 @@ fun SnoozeDurationPicker(
} }
} }
/** Whole hours if the delay divides evenly, else minutes. */ /** Whole hours if the length divides evenly, else minutes. */
private fun snoozeCustomUnit(minutes: Int): ReminderUnit = private fun durationCustomUnit(minutes: Int): ReminderUnit =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes 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 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 * 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 1999 * the length it resolves to, and a tonal confirm enabled only for a valid 1999
* amount. [onConfirm] receives the final delay in minutes. * amount. [onConfirm] receives the final length in minutes.
*/ */
@Composable @Composable
private fun CustomSnoozeEditor( private fun CustomDurationEditor(
amountText: String, amountText: String,
onAmountChange: (String) -> Unit, onAmountChange: (String) -> Unit,
unit: ReminderUnit, unit: ReminderUnit,

View File

@@ -37,3 +37,21 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60) pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes) 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)
}
}

View File

@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek 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.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
@@ -51,7 +52,7 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -113,6 +114,9 @@ class EventEditViewModel @Inject constructor(
// freezes the auto-applied default: switching calendars no longer overwrites // freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form. // their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false) 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 // 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. // reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null) 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 * 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 * 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) * 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 * or 09:00 (any other day). The event opens an hour long and is stretched to
* survives configuration changes; [reset] clears it when the screen closes. * 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) { fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return if (_form.value != null) return
@@ -300,9 +306,11 @@ class EventEditViewModel @Inject constructor(
} }
else -> LocalDateTime(date, LocalTime(9, 0)) 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) _form.value = EventForm(calendarId = null, start = start, end = end)
applyDefaultReminder() applyDefaultReminder()
applyDefaultDuration()
} }
/** /**
@@ -358,7 +366,9 @@ class EventEditViewModel @Inject constructor(
val form = _form.value ?: return@launch val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay) 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 // Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field. // removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty()) { if (reminders.isNotEmpty()) {
@@ -377,6 +387,33 @@ class EventEditViewModel @Inject constructor(
ReminderDefaults(timed, allDay, timedOv, allDayOv) ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first() }.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 * 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. * file often has none while the user still expects their configured default.
@@ -455,6 +492,7 @@ class EventEditViewModel @Inject constructor(
_editTarget.value = null _editTarget.value = null
_loadFailed.value = false _loadFailed.value = false
_remindersTouched.value = false _remindersTouched.value = false
_durationTouched.value = false
_importReminderPrompt.value = null _importReminderPrompt.value = null
} }
@@ -479,6 +517,9 @@ class EventEditViewModel @Inject constructor(
// The default reminder differs for all-day vs timed; re-apply the // The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded). // type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder() 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) { fun setCalendar(id: Long) {
update { it.copy(calendarId = id, colorKey = null, color = null) } update { it.copy(calendarId = id, colorKey = null, color = null) }
// A fresh event re-inherits the new calendar's default reminder unless // A fresh event re-inherits the new calendar's default reminder and
// the user has already hand-edited it (guarded inside). // length unless the user has already hand-edited them (guarded inside).
applyDefaultReminder(id) applyDefaultReminder(id)
applyDefaultDuration(id)
} }
fun setAvailability(value: Availability) = update { it.copy(availability = value) } fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = 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. */ /** Moving the start drags the end along, preserving the duration. */
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) } fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, 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 * Validate and write. Saving a dirty recurring event pauses in

View File

@@ -1,24 +1,38 @@
package de.jeanlucmakiola.calendula.ui.settings 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.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons 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.Keyboard
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventFormField 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.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf 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. */ /** New event form: which fields it opens with, and how it behaves. */
@Composable @Composable
@@ -27,6 +41,13 @@ internal fun EventFormScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, 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( CollapsingScaffold(
title = stringResource(R.string.settings_section_event_form), title = stringResource(R.string.settings_section_event_form),
onBack = onBack, onBack = onBack,
@@ -80,6 +101,64 @@ internal fun EventFormScreen(
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) }, 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 // Per-event colour on calendars that publish no colour set (some
// CalDAV); off by default, since it may not survive their next sync. // CalDAV); off by default, since it may not survive their next sync.
Spacer(Modifier.height(24.dp)) 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)

View File

@@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -38,10 +37,11 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip 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.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker 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.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
@@ -146,7 +146,7 @@ internal fun NotificationsScreen(
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_snooze_duration), title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes), summary = durationLabel(state.snoozeMinutes),
position = Position.Bottom, position = Position.Bottom,
onClick = { showSnooze = true }, onClick = { showSnooze = true },
) )
@@ -245,12 +245,13 @@ internal fun NotificationsScreen(
} }
if (showSnooze) { if (showSnooze) {
SnoozeDurationPicker( DurationPicker(
title = stringResource(R.string.settings_snooze_duration), title = stringResource(R.string.settings_snooze_duration),
presets = SNOOZE_PRESETS, presets = SNOOZE_PRESETS,
selected = state.snoozeMinutes, selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) }, label = { durationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) }, // No inherit row here, so a pick is never null.
onSelect = { minutes -> minutes?.let { viewModel.setSnoozeMinutes(it) } },
onDismiss = { showSnooze = false }, onDismiss = { showSnooze = false },
) )
} }
@@ -348,15 +349,6 @@ private fun calendarOverrideSummary(
/** Snooze delays offered for the notification "Snooze" action, in minutes. */ /** Snooze delays offered for the notification "Snooze" action, in minutes. */
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) 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 * Whether Calendula is exempt from battery optimisation, re-read on every
* `ON_RESUME` so a change made in system settings shows up at once. * `ON_RESUME` so a change made in system settings shows up at once.

View File

@@ -63,6 +63,10 @@ data class SettingsUiState(
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS, val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */ /** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
val autofocusEventTitle: Boolean = true, 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). */ /** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true, val remindersEnabled: Boolean = true,
/** /**

View File

@@ -123,8 +123,14 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarAllDayReminderOverride, prefs.perCalendarAllDayReminderOverride,
writableCalendars, writableCalendars,
prefs.managedCalendarIds, prefs.managedCalendarIds,
) { overrides, allDayOverrides, calendars, managedIds -> // The new-event duration defaults (#54) fold into one flow so they
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds) // 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( combine(
prefs.defaultView, prefs.defaultView,
@@ -198,6 +204,8 @@ class SettingsViewModel @Inject constructor(
perCalendarAllDayReminderOverride = overrides.allDay, perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars, writableCalendars = overrides.calendars,
managedCalendarIds = overrides.managedIds, managedCalendarIds = overrides.managedIds,
defaultEventDurationMinutes = overrides.durations.default,
perCalendarEventDuration = overrides.durations.perCalendar,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -260,6 +268,12 @@ class SettingsViewModel @Inject constructor(
val allDay: Map<Long, List<Int>>, val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>, val calendars: List<CalendarSource>,
val managedIds: Set<Long>, val managedIds: Set<Long>,
val durations: EventDurations,
)
private data class EventDurations(
val default: Int,
val perCalendar: Map<Long, Int>,
) )
private data class ViewSettings( private data class ViewSettings(
@@ -600,6 +614,15 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) } 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>) { fun setDefaultReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
} }

View File

@@ -242,6 +242,8 @@
<item quantity="one">%d day</item> <item quantity="one">%d day</item>
<item quantity="other">%d days</item> <item quantity="other">%d days</item>
</plurals> </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"> <plurals name="duration_weeks">
<item quantity="one">%d week</item> <item quantity="one">%d week</item>
<item quantity="other">%d weeks</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_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">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_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">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_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> <string name="settings_section_notifications">Notifications</string>

View File

@@ -475,6 +475,56 @@ class SettingsPrefsTest {
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1) 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 @Test
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest { fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))

View File

@@ -22,6 +22,9 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.Dispatchers 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.AfterEach
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -85,9 +88,11 @@ class EventEditViewModelTest {
private fun viewModel( private fun viewModel(
tempDir: Path, tempDir: Path,
fake: FakeCalendarDataSource, 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 { ): EventEditViewModel {
val p = prefs(tempDir) val p = prefs(tempDir)
val s = settings(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher) val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
return EventEditViewModel(repo, p, s, dispatcher) return EventEditViewModel(repo, p, s, dispatcher)
} }
@@ -200,6 +205,71 @@ class EventEditViewModelTest {
job.cancel() 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 @Test
fun `editing a recurring event without moving still asks for the scope`( fun `editing a recurring event without moving still asks for the scope`(
@TempDir tempDir: Path, @TempDir tempDir: Path,