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]
|
## [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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 1–999
|
* the length it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||||
* 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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user