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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 1–999
|
* 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
|
@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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user