Respect a source's own event length, cap custom durations (#54)

An imported event no longer has its end rewritten by the default-duration
setting: an .ics file, a duplicate and an insert intent that names its own
end keep their span, while an intent that gives only a start now gets the
default instead of a hardcoded hour.

Also caps the custom-duration editor at 24 hours, which is what the setting
stores, and skips the calendar query when no per-calendar override exists so
a new event's end doesn't shift after the form is on screen.
This commit is contained in:
2026-07-31 16:02:21 +02:00
parent 0f14fd2bfd
commit 5037972fb8
10 changed files with 139 additions and 30 deletions

View File

@@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
length underneath — 8 hours for the calendar you keep work shifts in, 30 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 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 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]). setting an end time by hand keeps it. An event another app hands over with only
a start gets the default too; one that names its own end — an `.ics` file, a
duplicate — keeps that length. All-day events are unaffected ([#54]).
## [2.17.1] — 2026-07-30 ## [2.17.1] — 2026-07-30

View File

@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.floret.components.DebugRibbon import de.jeanlucmakiola.floret.components.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.domain.FontRole
@@ -50,6 +51,9 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
/** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -69,7 +73,7 @@ class MainActivity : AppCompatActivity() {
// A prefilled new-event form from an external ACTION_INSERT launch (another // A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30). Consumed once by // app/widget asking us to create an event, issue #30). Consumed once by
// CalendarHost, which opens it in the create form for review. // CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null) private var requestedInsert by mutableStateOf<InsertRequest?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>): // An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the // opens the occurrence in the edit form. Same occurrence-key shape as the
@@ -100,7 +104,7 @@ class MainActivity : AppCompatActivity() {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull() requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull() requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull() requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
@@ -151,8 +155,9 @@ class MainActivity : AppCompatActivity() {
onWidgetNavConsumed = { requestedNav = null }, onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm, requestedInsertForm = requestedInsert?.form,
onInsertConsumed = { requestedInsertForm = null }, requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
requestedEditKey = requestedEditKey, requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null }, onEditKeyConsumed = { requestedEditKey = null },
) )
@@ -192,7 +197,7 @@ class MainActivity : AppCompatActivity() {
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it } (intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it } intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it } intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it } intent.insertRequestOrNull()?.let { requestedInsert = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it } intent.editEventKeyOrNull()?.let { requestedEditKey = it }
} }
@@ -219,9 +224,10 @@ class MainActivity : AppCompatActivity() {
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new * (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
* event", i.e. create). The new event's fields ride as CalendarContract * event", i.e. create). The new event's fields ride as CalendarContract
* extras; anything omitted falls back to the in-app "new event" defaults in * extras; anything omitted falls back to the in-app "new event" defaults in
* [buildInsertEventForm]. * [buildInsertEventForm]. An intent that names no end time leaves the length
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
*/ */
private fun Intent.insertFormOrNull(): EventForm? { private fun Intent.insertRequestOrNull(): InsertRequest? {
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the // ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
// edit form instead ([editEventKeyOrNull]); an id-less one is a create, // edit form instead ([editEventKeyOrNull]); an id-less one is a create,
// as is any plain ACTION_INSERT. // as is any plain ACTION_INSERT.
@@ -229,9 +235,11 @@ class MainActivity : AppCompatActivity() {
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) && ((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
editEventKeyOrNull() == null) editEventKeyOrNull() == null)
if (!isCreate) return null if (!isCreate) return null
return buildInsertEventForm( val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME), val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME), val form = buildInsertEventForm(
beginMillis = beginMillis,
endMillis = endMillis,
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false), isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
title = getStringExtra(CalendarContract.Events.TITLE), title = getStringExtra(CalendarContract.Events.TITLE),
description = getStringExtra(CalendarContract.Events.DESCRIPTION), description = getStringExtra(CalendarContract.Events.DESCRIPTION),
@@ -240,6 +248,10 @@ class MainActivity : AppCompatActivity() {
zone = TimeZone.currentSystemDefault(), zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(), now = Clock.System.now(),
) )
// An end the intent didn't name — or one [buildInsertEventForm] drops for
// landing before the start — leaves the length to the setting.
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
} }
/** A Long extra's value, or null when the extra is absent. */ /** A Long extra's value, or null when the extra is absent. */

View File

@@ -15,7 +15,9 @@ import kotlin.time.Instant
* calendar this way to create a new event, passing the fields as * calendar this way to create a new event, passing the fields as
* [android.provider.CalendarContract] extras. Any field the intent omits falls * [android.provider.CalendarContract] extras. Any field the intent omits falls
* back to the same defaults the in-app "new event" uses — a timed start at the * back to the same defaults the in-app "new event" uses — a timed start at the
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so * next full hour, and a placeholder hour for a missing end that the form then
* stretches to the default-duration setting (the intent is opened as
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
* it resolves to the last-used / first-writable calendar, exactly like the * it resolves to the last-used / first-writable calendar, exactly like the
* `.ics` single-event and plain new-event paths. * `.ics` single-event and plain new-event paths.
* *

View File

@@ -75,6 +75,7 @@ fun CalendarHost(
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null, requestedInsertForm: EventForm? = null,
requestedInsertSource: ImportSource = ImportSource.Insert,
onInsertConsumed: () -> Unit = {}, onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null, requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {}, onEditKeyConsumed: () -> Unit = {},
@@ -203,7 +204,7 @@ fun CalendarHost(
// reveals on top of whatever was open without extra dismissal. // reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) { LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) { if (requestedInsertForm != null) {
importFormSource = ImportSource.Insert importFormSource = requestedInsertSource
importForm = requestedInsertForm importForm = requestedInsertForm
onInsertConsumed() onInsertConsumed()
} }

View File

@@ -39,6 +39,8 @@ fun RootScreen(
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null, requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {}, onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null, requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {}, onEditKeyConsumed: () -> Unit = {},
@@ -109,6 +111,7 @@ fun RootScreen(
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed, onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm, requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed, onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey, requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed, onEditKeyConsumed = onEditKeyConsumed,

View File

@@ -365,7 +365,9 @@ private fun CustomDaysEditor(
* [inheritLabel] adds an exclusive "use the default" row on top, for the * [inheritLabel] adds an exclusive "use the default" row on top, for the
* per-calendar pickers; picking it reports null. [selected] is null exactly when * per-calendar pickers; picking it reports null. [selected] is null exactly when
* that row is the current choice. [description] explains the setting above the * that row is the current choice. [description] explains the setting above the
* rows when it needs it. * rows when it needs it. [maxMinutes] caps what the custom editor will confirm,
* for settings that clamp their stored value — offering a length the store would
* silently shorten is worse than refusing it.
*/ */
@Composable @Composable
fun DurationPicker( fun DurationPicker(
@@ -377,6 +379,7 @@ fun DurationPicker(
onDismiss: () -> Unit, onDismiss: () -> Unit,
description: String? = null, description: String? = null,
inheritLabel: String? = null, inheritLabel: String? = null,
maxMinutes: Int = Int.MAX_VALUE,
) { ) {
val inherits = selected == null val inherits = selected == null
// The current choice when it isn't one of the presets — the Custom row then // The current choice when it isn't one of the presets — the Custom row then
@@ -452,6 +455,7 @@ fun DurationPicker(
unit = unit, unit = unit,
onUnitChange = { unit = it }, onUnitChange = { unit = it },
label = label, label = label,
maxMinutes = maxMinutes,
onConfirm = { minutes -> onConfirm = { minutes ->
onSelect(minutes) onSelect(minutes)
onDismiss() onDismiss()
@@ -472,7 +476,9 @@ private fun durationCustomAmount(minutes: Int): Int =
* The expanded "Custom" duration 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 length it resolves to, and a tonal confirm enabled only for a valid 1999 * the length it resolves to, and a tonal confirm enabled only for a valid 1999
* amount. [onConfirm] receives the final length in minutes. * amount that stays within [maxMinutes]. Over the cap, the preview names the
* limit instead of a length the setting could not keep. [onConfirm] receives the
* final length in minutes.
*/ */
@Composable @Composable
private fun CustomDurationEditor( private fun CustomDurationEditor(
@@ -481,21 +487,26 @@ private fun CustomDurationEditor(
unit: ReminderUnit, unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit, onUnitChange: (ReminderUnit) -> Unit,
label: @Composable (Int) -> String, label: @Composable (Int) -> String,
maxMinutes: Int,
onConfirm: (Int) -> Unit, onConfirm: (Int) -> Unit,
) { ) {
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) } val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 } val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
val amount = entered?.takeIf { it <= maxMinutes }
CustomAmountEditor( CustomAmountEditor(
amountText = amountText, amountText = amountText,
onAmountChange = onAmountChange, onAmountChange = onAmountChange,
unitLabels = units.map { stringResource(reminderUnitLabel(it)) }, unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = units.indexOf(unit).coerceAtLeast(0), selectedUnit = units.indexOf(unit).coerceAtLeast(0),
onUnitChange = { onUnitChange(units[it]) }, onUnitChange = { onUnitChange(units[it]) },
preview = amount?.let { label(it * unit.minutesFactor) } preview = when {
?: stringResource(R.string.reminder_custom_amount), amount != null -> label(amount)
entered != null -> stringResource(R.string.duration_custom_max, label(maxMinutes))
else -> stringResource(R.string.reminder_custom_amount)
},
setLabel = stringResource(R.string.reminder_custom_set), setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null, confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } }, onConfirm = { amount?.let(onConfirm) },
) )
} }

View File

@@ -57,23 +57,33 @@ import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
/** /**
* Where a prefilled [EventEditViewModel.openImported] form came from — the two * Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* sources want different reminder handling (#49). * want different reminder handling (#49), and differ in whether they own the
* event's length ([ownsDuration], #54) — one that does is never restretched to the
* default-duration setting.
*/ */
enum class ImportSource { enum class ImportSource(internal val ownsDuration: Boolean) {
/** /**
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps). * An external `ACTION_INSERT` intent that named both ends (another app/widget,
* It carries no reminders of its own, so the settings default is applied * e.g. Google Maps), or an in-app duplicate. It carries no reminders of its
* automatically, exactly like an in-app new event. * own, so the settings default is applied automatically, exactly like an
* in-app new event; its span, though, is the caller's.
*/ */
Insert, Insert(ownsDuration = true),
/**
* An `ACTION_INSERT` that named only a start. Reminders behave as for
* [Insert], but the length is ours to pick, so the default-duration setting
* applies just like an in-app new event.
*/
InsertOpenEnded(ownsDuration = false),
/** /**
* A parsed single-event `.ics` file. Its own reminders are respected; the * A parsed single-event `.ics` file. Its own reminders are respected; the
* settings default is offered through [EventEditViewModel.importReminderPrompt] * settings default is offered through [EventEditViewModel.importReminderPrompt]
* rather than silently applied or suppressed. * rather than silently applied or suppressed. Its span is the file's.
*/ */
File, File(ownsDuration = true),
} }
/** /**
@@ -329,6 +339,12 @@ class EventEditViewModel @Inject constructor(
* settings default is configured and differs, [importReminderPrompt] offers * settings default is configured and differs, [importReminderPrompt] offers
* to swap it in rather than silently deciding for the user. * to swap it in rather than silently deciding for the user.
* *
* The length is frozen the same way: a source that named the event's end
* ([ImportSource.ownsDuration]) keeps it, so the default-duration setting
* never silently rewrites a span someone else chose; only an
* [ImportSource.InsertOpenEnded] intent — a start with no end — gets the
* default applied.
*
* No-op when a form is already open, so the prefill survives configuration * No-op when a form is already open, so the prefill survives configuration
* changes. * changes.
*/ */
@@ -336,8 +352,9 @@ class EventEditViewModel @Inject constructor(
if (_form.value != null || _editTarget.value != null) return if (_form.value != null || _editTarget.value != null) return
_revealed.value = form.populatedFields() _revealed.value = form.populatedFields()
_form.value = form _form.value = form
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) { when (source) {
ImportSource.Insert -> ImportSource.Insert, ImportSource.InsertOpenEnded ->
if (form.reminders.isNotEmpty()) _remindersTouched.value = true if (form.reminders.isNotEmpty()) _remindersTouched.value = true
else applyDefaultReminder() else applyDefaultReminder()
@@ -400,7 +417,13 @@ class EventEditViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val global = settingsPrefs.defaultEventDurationMinutes.first() val global = settingsPrefs.defaultEventDurationMinutes.first()
val overrides = settingsPrefs.perCalendarEventDuration.first() val overrides = settingsPrefs.perCalendarEventDuration.first()
val targetId = calendarId ?: resolvedCalendarId.first() // Only resolve the calendar when an override could actually apply —
// that read waits on the provider query, and the stretch would then
// land after the user can already see (and accept) the end time.
val targetId = when {
overrides.isEmpty() -> null
else -> calendarId ?: resolvedCalendarId.first()
}
// Re-check after suspending: bail if the form closed or the user edited. // Re-check after suspending: bail if the form closed or the user edited.
if (_editTarget.value != null || _durationTouched.value) return@launch if (_editTarget.value != null || _durationTouched.value) return@launch
if (_form.value?.isAllDay != false) return@launch if (_form.value?.isAllDay != false) return@launch

View File

@@ -21,6 +21,7 @@ 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.data.prefs.SettingsPrefs
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.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker import de.jeanlucmakiola.calendula.ui.common.DurationPicker
@@ -190,6 +191,7 @@ internal fun EventFormScreen(
// No inherit row on the global default, so a pick is never null. // No inherit row on the global default, so a pick is never null.
onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } }, onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } },
onDismiss = { showDefaultDuration = false }, onDismiss = { showDefaultDuration = false },
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
) )
} }
durationTarget?.let { calendarId -> durationTarget?.let { calendarId ->
@@ -204,6 +206,7 @@ internal fun EventFormScreen(
), ),
onSelect = { viewModel.setCalendarEventDuration(calendarId, it) }, onSelect = { viewModel.setCalendarEventDuration(calendarId, it) },
onDismiss = { durationTarget = null }, onDismiss = { durationTarget = null },
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
) )
} }
} }

View File

@@ -244,6 +244,8 @@
</plurals> </plurals>
<!-- A mixed duration: %1$s is the hours part, %2$s the minutes part. --> <!-- A mixed duration: %1$s is the hours part, %2$s the minutes part. -->
<string name="duration_hours_minutes">%1$s %2$s</string> <string name="duration_hours_minutes">%1$s %2$s</string>
<!-- Shown instead of the preview when a custom length exceeds what the setting keeps. %1$s is the longest allowed, e.g. "24 hours". -->
<string name="duration_custom_max">At most %1$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>

View File

@@ -10,6 +10,7 @@ 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.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -249,6 +250,55 @@ class EventEditViewModelTest {
job.cancel() job.cancel()
} }
@Test
fun `an imported event keeps the span its source named`(
@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.openImported(importedForm(end = LocalDateTime(2030, 1, 15, 11, 30)), ImportSource.File)
advanceUntilIdle()
vm.setCalendar(2L)
advanceUntilIdle()
// The file owns the length; a calendar switch must not restretch it.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
job.cancel()
}
@Test
fun `an insert intent with no end takes the default duration`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
s.setDefaultEventDurationMinutes(30)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
// The placeholder hour buildInsertEventForm fills in for a missing end.
vm.openImported(
importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)),
ImportSource.InsertOpenEnded,
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
job.cancel()
}
private fun importedForm(end: LocalDateTime) = EventForm(
calendarId = null,
title = "Imported",
start = LocalDateTime(2030, 1, 15, 9, 0),
end = end,
)
@Test @Test
fun `an all-day event ignores the default duration`( fun `an all-day event ignores the default duration`(
@TempDir tempDir: Path, @TempDir tempDir: Path,