From 0f14fd2bfdb1fde9bcd3e397d6c5ab3d269b4bba Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 31 Jul 2026 15:24:03 +0200 Subject: [PATCH] Let a new event's length be a setting, per calendar (#54) --- CHANGELOG.md | 9 ++ .../calendula/data/prefs/SettingsPrefs.kt | 81 +++++++++++++ .../calendula/ui/common/Picker.kt | 76 ++++++++---- .../calendula/ui/common/ReminderFormatting.kt | 18 +++ .../calendula/ui/edit/EventEditViewModel.kt | 70 +++++++++-- .../ui/settings/EventFormSettings.kt | 109 ++++++++++++++++++ .../ui/settings/NotificationSettings.kt | 22 ++-- .../calendula/ui/settings/SettingsUiState.kt | 4 + .../ui/settings/SettingsViewModel.kt | 27 ++++- app/src/main/res/values/strings.xml | 10 ++ .../calendula/data/prefs/SettingsPrefsTest.kt | 50 ++++++++ .../ui/edit/EventEditViewModelTest.kt | 72 +++++++++++- 12 files changed, 497 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7645c7..6c11761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 52a2518..3eddf7d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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> = 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, + 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 = when { private fun List.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 = + 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.toStoredDurations(): String = + entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) -> + "$id$KEY_VALUE_SEP$minutes" + } + private inline fun > String?.toEnum(default: E): E = this?.let { stored -> enumValues().firstOrNull { it.name == stored } } ?: default diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index 44ab0b1..9fdfee4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -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, - 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt index 74da165..1ee9778 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 93e3c1d..8422d5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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(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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt index 585fd28..53a8c05 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -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(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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index 3735b30..0b62f48 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 5c5c19e..ddeda21 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -63,6 +63,10 @@ data class SettingsUiState( val defaultFormFields: Set = 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 = emptyMap(), /** Whether Calendula posts reminder notifications (v1.4). */ val remindersEnabled: Boolean = true, /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 0e6000d..43226a0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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>, val calendars: List, val managedIds: Set, + val durations: EventDurations, + ) + + private data class EventDurations( + val default: Int, + val perCalendar: Map, ) 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) { viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cedc6ff..48dc33b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,6 +242,8 @@ %d day %d days + + %1$s %2$s %d week %d weeks @@ -428,6 +430,14 @@ Fields shown by default — everything else sits behind \"More fields\" Focus title on new event When you start a new event, place the cursor in the title field and open the keyboard right away. + Default duration + How long a new event lasts until you change its end time. All-day events aren\'t affected. + Per-calendar duration + Give a calendar its own default length — e.g. 8 hours for work shifts. + + Default (%1$s) + + Use default duration (%1$s) Allow colors on unsupported calendars 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. Notifications diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 7693801..cba95fd 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index e087fbe..ea04c55 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -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,