From 5037972fb83c51f9686f323365c0fb84e0579ef1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 31 Jul 2026 16:02:21 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 4 +- .../jeanlucmakiola/calendula/MainActivity.kt | 32 ++++++++---- .../calendula/domain/InsertEventForm.kt | 4 +- .../calendula/ui/CalendarHost.kt | 3 +- .../jeanlucmakiola/calendula/ui/RootScreen.kt | 3 ++ .../calendula/ui/common/Picker.kt | 23 ++++++--- .../calendula/ui/edit/EventEditViewModel.kt | 45 +++++++++++++---- .../ui/settings/EventFormSettings.kt | 3 ++ app/src/main/res/values/strings.xml | 2 + .../ui/edit/EventEditViewModelTest.kt | 50 +++++++++++++++++++ 10 files changed, 139 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c11761..51f0457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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]). + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 0a0c20f..cb1822c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.common.CalendarView 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.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.domain.FontRole @@ -50,6 +51,9 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock 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 class MainActivity : AppCompatActivity() { @@ -69,7 +73,7 @@ class MainActivity : AppCompatActivity() { // 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 // CalendarHost, which opens it in the create form for review. - private var requestedInsertForm by mutableStateOf(null) + private var requestedInsert by mutableStateOf(null) // An external "edit this event" (ACTION_EDIT on content://.../events/): // 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() requestedNav = intent.navRequestOrNull() requestedImportUri = intent.importUriOrNull() - requestedInsertForm = intent.insertFormOrNull() + requestedInsert = intent.insertRequestOrNull() requestedEditKey = intent.editEventKeyOrNull() if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) setContent { @@ -151,8 +155,9 @@ class MainActivity : AppCompatActivity() { onWidgetNavConsumed = { requestedNav = null }, requestedImportUri = requestedImportUri, onImportConsumed = { requestedImportUri = null }, - requestedInsertForm = requestedInsertForm, - onInsertConsumed = { requestedInsertForm = null }, + requestedInsertForm = requestedInsert?.form, + requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert, + onInsertConsumed = { requestedInsert = null }, requestedEditKey = requestedEditKey, onEditKeyConsumed = { requestedEditKey = null }, ) @@ -192,7 +197,7 @@ class MainActivity : AppCompatActivity() { (intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it } intent.navRequestOrNull()?.let { requestedNav = it } intent.importUriOrNull()?.let { requestedImportUri = it } - intent.insertFormOrNull()?.let { requestedInsertForm = it } + intent.insertRequestOrNull()?.let { requestedInsert = 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 * 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 - * [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 // edit form instead ([editEventKeyOrNull]); an id-less one is a create, // as is any plain ACTION_INSERT. @@ -229,9 +235,11 @@ class MainActivity : AppCompatActivity() { ((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) && editEventKeyOrNull() == null) if (!isCreate) return null - return buildInsertEventForm( - beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME), - endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME), + val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) + val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) + val form = buildInsertEventForm( + beginMillis = beginMillis, + endMillis = endMillis, isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false), title = getStringExtra(CalendarContract.Events.TITLE), description = getStringExtra(CalendarContract.Events.DESCRIPTION), @@ -240,6 +248,10 @@ class MainActivity : AppCompatActivity() { zone = TimeZone.currentSystemDefault(), 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt index 059f301..2dd0db8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt @@ -15,7 +15,9 @@ import kotlin.time.Instant * calendar this way to create a new event, passing the fields as * [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 - * 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 * `.ics` single-event and plain new-event paths. * diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 41f3f21..02f10a3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -75,6 +75,7 @@ fun CalendarHost( requestedImportUri: android.net.Uri? = null, onImportConsumed: () -> Unit = {}, requestedInsertForm: EventForm? = null, + requestedInsertSource: ImportSource = ImportSource.Insert, onInsertConsumed: () -> Unit = {}, requestedEditKey: LongArray? = null, onEditKeyConsumed: () -> Unit = {}, @@ -203,7 +204,7 @@ fun CalendarHost( // reveals on top of whatever was open without extra dismissal. LaunchedEffect(requestedInsertForm) { if (requestedInsertForm != null) { - importFormSource = ImportSource.Insert + importFormSource = requestedInsertSource importForm = requestedInsertForm onInsertConsumed() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 49e90d0..14d4525 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -39,6 +39,8 @@ fun RootScreen( requestedImportUri: android.net.Uri? = null, onImportConsumed: () -> Unit = {}, requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null, + requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource = + de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert, onInsertConsumed: () -> Unit = {}, requestedEditKey: LongArray? = null, onEditKeyConsumed: () -> Unit = {}, @@ -109,6 +111,7 @@ fun RootScreen( requestedImportUri = requestedImportUri, onImportConsumed = onImportConsumed, requestedInsertForm = requestedInsertForm, + requestedInsertSource = requestedInsertSource, onInsertConsumed = onInsertConsumed, requestedEditKey = requestedEditKey, onEditKeyConsumed = onEditKeyConsumed, 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 9fdfee4..6c25a2c 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 @@ -365,7 +365,9 @@ private fun CustomDaysEditor( * [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. + * 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 fun DurationPicker( @@ -377,6 +379,7 @@ fun DurationPicker( onDismiss: () -> Unit, description: String? = null, inheritLabel: String? = null, + maxMinutes: Int = Int.MAX_VALUE, ) { val inherits = selected == null // The current choice when it isn't one of the presets — the Custom row then @@ -452,6 +455,7 @@ fun DurationPicker( unit = unit, onUnitChange = { unit = it }, label = label, + maxMinutes = maxMinutes, onConfirm = { minutes -> onSelect(minutes) onDismiss() @@ -472,7 +476,9 @@ private fun durationCustomAmount(minutes: Int): Int = * 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 length it resolves to, and a tonal confirm enabled only for a valid 1–999 - * 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 private fun CustomDurationEditor( @@ -481,21 +487,26 @@ private fun CustomDurationEditor( unit: ReminderUnit, onUnitChange: (ReminderUnit) -> Unit, label: @Composable (Int) -> String, + maxMinutes: Int, onConfirm: (Int) -> Unit, ) { 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( amountText = amountText, onAmountChange = onAmountChange, unitLabels = units.map { stringResource(reminderUnitLabel(it)) }, selectedUnit = units.indexOf(unit).coerceAtLeast(0), onUnitChange = { onUnitChange(units[it]) }, - preview = amount?.let { label(it * unit.minutesFactor) } - ?: stringResource(R.string.reminder_custom_amount), + preview = when { + 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), confirmEnabled = amount != null, - onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } }, + onConfirm = { amount?.let(onConfirm) }, ) } 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 8422d5a..2c3dc61 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 @@ -57,23 +57,33 @@ import kotlin.time.Instant import javax.inject.Inject /** - * Where a prefilled [EventEditViewModel.openImported] form came from — the two - * sources want different reminder handling (#49). + * Where a prefilled [EventEditViewModel.openImported] form came from. The sources + * 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). - * It carries no reminders of its own, so the settings default is applied - * automatically, exactly like an in-app new event. + * An external `ACTION_INSERT` intent that named both ends (another app/widget, + * e.g. Google Maps), or an in-app duplicate. It carries no reminders of its + * 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 * 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 * 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 * changes. */ @@ -336,8 +352,9 @@ class EventEditViewModel @Inject constructor( if (_form.value != null || _editTarget.value != null) return _revealed.value = form.populatedFields() _form.value = form + if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration() when (source) { - ImportSource.Insert -> + ImportSource.Insert, ImportSource.InsertOpenEnded -> if (form.reminders.isNotEmpty()) _remindersTouched.value = true else applyDefaultReminder() @@ -400,7 +417,13 @@ class EventEditViewModel @Inject constructor( viewModelScope.launch { val global = settingsPrefs.defaultEventDurationMinutes.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. if (_editTarget.value != null || _durationTouched.value) return@launch if (_form.value?.isAllDay != false) return@launch 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 53a8c05..61ffe0a 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 @@ -21,6 +21,7 @@ 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.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip 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. onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } }, onDismiss = { showDefaultDuration = false }, + maxMinutes = SettingsPrefs.MAX_EVENT_DURATION, ) } durationTarget?.let { calendarId -> @@ -204,6 +206,7 @@ internal fun EventFormScreen( ), onSelect = { viewModel.setCalendarEventDuration(calendarId, it) }, onDismiss = { durationTarget = null }, + maxMinutes = SettingsPrefs.MAX_EVENT_DURATION, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48dc33b..b3f8381 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -244,6 +244,8 @@ %1$s %2$s + + At most %1$s %d week %d weeks 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 ea04c55..cb3b8de 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 @@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -249,6 +250,55 @@ class EventEditViewModelTest { 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 fun `an all-day event ignores the default duration`( @TempDir tempDir: Path,