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:
@@ -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
|
||||
|
||||
|
||||
@@ -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<EventForm?>(null)
|
||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||
// 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. */
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,8 @@
|
||||
</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>
|
||||
<!-- 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">
|
||||
<item quantity="one">%d week</item>
|
||||
<item quantity="other">%d weeks</item>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user