From f410e9db8bc812b3a0f8498ff06ac7dc64082fab Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 14 Oct 2026 15:00:53 +0200 Subject: [PATCH] Follow the hand when a time is picked in the repeated hour (#387) A time picked in the fall-back hour's repeat, in the form or by a resize handle, now gets its pass from one rule. It keeps the pass the event was opened with when it is that same time again. Otherwise it moves the way the hand did, and stays on the right side of the event's other edge where it can. - The form can set an end in the hour after a second-pass start, and a time changed and changed back is the time it was. - Pulling a resize handle up the repeated hour shortens the event. - A block running into the repeat from before it is drawn to the hour's end, so a longer one never looks shorter. - A drafted block opens the form at its own start and end instants. - The repeated-hour zone label skips the long zone name, and the detail screen's time lines are remembered. --- .../calendula/domain/EventShift.kt | 63 +++++++++++++++++-- .../calendula/domain/TimeZoneCatalog.kt | 22 ++++++- .../calendula/ui/CalendarHost.kt | 28 +++++---- .../calendula/ui/common/TimeFormat.kt | 7 +-- .../calendula/ui/common/TimelineDrag.kt | 25 +++++--- .../calendula/ui/detail/EventDetailScreen.kt | 18 ++++-- .../calendula/ui/edit/EventEditScreen.kt | 9 ++- .../calendula/ui/edit/EventEditViewModel.kt | 60 +++++++++++------- .../calendula/ui/week/WeekScreen.kt | 18 +++--- .../calendula/ui/week/WeekViewModel.kt | 30 +++++---- .../calendula/domain/EventFormTest.kt | 60 +++++++++++++++--- .../calendula/ui/common/TimelineDropTest.kt | 11 ++++ .../ui/edit/EventEditViewModelTest.kt | 54 ++++++++++++---- .../calendula/ui/week/WeekLayoutTest.kt | 13 ++++ 14 files changed, 317 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt index 61c843d..2f310c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -7,7 +7,9 @@ import kotlinx.datetime.UtcOffset import kotlinx.datetime.offsetAt import kotlinx.datetime.plus import kotlinx.datetime.toInstant +import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toJavaZoneId +import kotlinx.datetime.toKotlinUtcOffset import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant import kotlin.time.toJavaInstant @@ -47,6 +49,42 @@ fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone /** This time as an instant in [zone], on its kept pass of a repeated hour. */ fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offset) +/** + * This edge of an event moved to [picked] by hand — a picker or a resize handle — + * as a [FormTime] in [zone]. A wall clock the fall-back hour repeats has two + * instants; which one it means (#387): + * - [original]'s, when [picked] is the wall clock the event was opened with, so + * a time changed and changed back is the time it was; + * - else the one that moves the edge the way the hand did: earlier than this + * edge's instant when [picked] is before [shownAt] — where the edge was on + * screen — later when after; + * - kept within [notBefore]..[notAfter] (the other edge) when either fits. + */ +fun FormTime.repicked( + picked: LocalDateTime, + zone: TimeZone, + shownAt: LocalDateTime = dateTime, + original: FormTime? = null, + notBefore: Instant? = null, + notAfter: Instant? = null, +): FormTime { + if (picked == dateTime && picked == shownAt) return this + val zid = zone.toJavaZoneId() + val offsets = zid.rules.getValidOffsets(picked.toJavaLocalDateTime()) + if (offsets.size < 2) return FormTime(picked) + val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted() + val fitting = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) } + .ifEmpty { passes } + if (original?.dateTime == picked && original.offset != null && original.instantIn(zone) in fitting) return original + val from = instantIn(zone) + val chosen = when { + picked < shownAt -> fitting.lastOrNull { it < from } + picked > shownAt -> fitting.firstOrNull { it > from } + else -> null + } ?: fitting.minBy { (it - from).absoluteValue } + return chosen.toFormTime(zone) +} + /** The form's start as an instant in [zone]. */ fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone) @@ -64,16 +102,29 @@ fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm { val pinned = copy(timezone = zoneId) if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned - return pinned.copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime)) + return pinned.withoutKeptPasses() } /** - * The form with its end picked by hand. A picked wall-clock time can't say which - * pass of a repeated hour it means, so it takes the earlier — unless it is the - * end the form already has. + * The form with its start picked by hand, keeping its length. A repeated hour's + * pass is [repicked]: [original] is the start the event was opened with. */ -fun EventForm.withEnd(end: LocalDateTime): EventForm = - if (end == this.end.dateTime) this else copy(end = FormTime(end)) +fun EventForm.withStart(start: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm { + val span = endInstant(zone) - startInstant(zone) + val startAt = this.start.repicked(start, zone, original = original).instantIn(zone) + return withTimes(startAt, startAt + span, zone) +} + +/** + * The form with its end picked by hand. A repeated hour's pass is [repicked], + * kept after the start where it can be: [original] is the end the event was + * opened with. + */ +fun EventForm.withEnd(end: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm = + copy(end = this.end.repicked(end, zone, original = original, notBefore = startInstant(zone))) + +/** The form with no kept passes: its times as the wall clock alone says them. */ +fun EventForm.withoutKeptPasses(): EventForm = copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime)) /** * The form moved so it starts at [newStart], keeping its **instant** duration — diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index 49ed06c..af6dca0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -166,11 +166,27 @@ fun zoneDescriptor(option: TimeZoneOption): String { * [option]'s short name on its own — "CEST", "UTC" — with an offset-shaped one * normalised to our GMT form, as [zoneDescriptor] spells it. */ -fun zoneAbbreviation(option: TimeZoneOption): String { - val abbrev = option.shortName - return if (abbrev.startsWith("GMT")) formatGmtOffset(option.offsetMinutes) else abbrev +fun zoneAbbreviation(option: TimeZoneOption): String = abbreviation(option.shortName, option.offsetMinutes) + +/** + * Zone [id]'s abbreviation [at] an instant, as [zoneAbbreviation] spells it, + * without the localized long name a whole [TimeZoneOption] would look up. Null + * for an id the tz database can't parse. + */ +fun zoneAbbreviationAt( + id: String, + locale: Locale, + at: Instant, + regionOf: (String) -> String? = { null }, +): String? { + val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null + val shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf) + return abbreviation(shortName, zone.rules.getOffset(at).totalSeconds / 60) } +private fun abbreviation(shortName: String, offsetMinutes: Int): String = + if (shortName.startsWith("GMT")) formatGmtOffset(offsetMinutes) else shortName + /** * [options] matching [query], best matches first; a blank query returns the list * unchanged. Matching is accent- and case-insensitive and treats underscores as 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 2b62c13..23b2b09 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -95,6 +95,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock +import kotlin.time.Instant /** * Holds the top-level view back stack (spec M1) and swaps between the calendar @@ -239,18 +240,23 @@ fun CalendarHost( var heldCreateIso by remember { mutableStateOf(null) } var createStartMinutes by rememberSaveable { mutableStateOf(null) } var heldCreateMinutes by remember { mutableStateOf(null) } - // A drafted block's length (#153), in minutes. - var createDurationMinutes by rememberSaveable { mutableStateOf(null) } - var heldCreateDurationMinutes by remember { mutableStateOf(null) } - val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, durationMinutes -> + // A drafted block's start and end (#153), as epoch millis: the instants it + // shows, which minutes on a day could only say again in some zone (#387). + var createDraftMillis by rememberSaveable { mutableStateOf(null) } + var heldCreateDraftMillis by remember { mutableStateOf(null) } + fun openCreate(date: LocalDate, startMinutes: Int?, draftMillis: LongArray?) { heldCreateIso = date.toString() createDateIso = date.toString() heldCreateMinutes = startMinutes createStartMinutes = startMinutes - heldCreateDurationMinutes = durationMinutes - createDurationMinutes = durationMinutes + heldCreateDraftMillis = draftMillis + createDraftMillis = draftMillis + } + val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, null) } + val onCreateDraft: (Instant, Instant) -> Unit = { start, end -> + val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date + openCreate(date, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds())) } - val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> onCreateSpan(date, startMinutes, null) } // Edit form (v1.3) — reuses the detail screen's occurrence key; for // recurring events the form itself asks for the write scope at save @@ -386,8 +392,8 @@ fun CalendarHost( createDateIso = iso heldCreateMinutes = null createStartMinutes = null - heldCreateDurationMinutes = null - createDurationMinutes = null + heldCreateDraftMillis = null + createDraftMillis = null onWidgetNavConsumed() } null -> {} @@ -591,7 +597,7 @@ fun CalendarHost( onOpenDay = onOpenDay, onEventClick = onEventClick, onCreateEvent = onCreateEvent, - onCreateSpan = onCreateSpan, + onCreateDraft = onCreateDraft, ) ViewScreen.Month -> MonthScreen( onOpenDay = onOpenDay, @@ -680,7 +686,7 @@ fun CalendarHost( EventEditScreen( initialDateIso = iso, initialStartMinutes = createStartMinutes ?: heldCreateMinutes, - initialDurationMinutes = createDurationMinutes ?: heldCreateDurationMinutes, + initialDraft = createDraftMillis ?: heldCreateDraftMillis, onClose = { createDateIso = null }, onSaved = { createDateIso = null }, onManageCalendars = { showCalendars = true }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt index e2b43ab..4e6bc67 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt @@ -2,8 +2,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.runtime.staticCompositionLocalOf import de.jeanlucmakiola.calendula.domain.inRepeatedHour -import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf -import de.jeanlucmakiola.calendula.domain.zoneAbbreviation +import de.jeanlucmakiola.calendula.domain.zoneAbbreviationAt import kotlinx.datetime.TimeZone import java.time.LocalTime import kotlin.time.Instant @@ -82,7 +81,7 @@ private const val MINUTES_PER_DAY = 1_440 */ fun withRepeatedHourZone(formatted: String, instant: Instant, zone: TimeZone, locale: Locale): String { if (!instant.inRepeatedHour(zone)) return formatted - val option = timeZoneOptionOf(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion) + val abbreviation = zoneAbbreviationAt(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion) ?: return formatted - return "$formatted ${zoneAbbreviation(option)}" + return "$formatted $abbreviation" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 9afa7eb..30a1ba1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -46,7 +46,10 @@ import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.instantIn import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt +import de.jeanlucmakiola.calendula.domain.repicked +import de.jeanlucmakiola.calendula.domain.toFormTime import de.jeanlucmakiola.calendula.domain.resolveIn import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY import de.jeanlucmakiola.calendula.ui.week.TimedBlock @@ -323,9 +326,8 @@ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val * [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s * midnight. Only the held edge moves. The other keeps its own instant rather than * a round trip through the grid, where an end in the fall-back hour's repeat is - * drawn to that hour's end, not at its own time (#387). The held edge keeps its - * own pass of a repeated hour, else the other edge's: an end pulled into the - * later pass an event starts in stays after that start. + * drawn to that hour's end, not at its own time (#387). The held edge's pass of a + * repeated hour follows the hand from where the edge was drawn: see [repicked]. */ internal fun resizedDrop( event: EventInstance, @@ -335,17 +337,26 @@ internal fun resizedDrop( spanMin: Int, zone: TimeZone, ): TimelineResizeDrop { - val startKept = zone.repeatedHourOffsetAt(event.start) - val endKept = zone.repeatedHourOffsetAt(event.end) + val drawn = wallClockSpan(event, zone) return TimelineResizeDrop( event = event, start = if (edge == ResizeEdge.Top) { - wallClockInstant(date, startMin, zone, kept = startKept ?: endKept) + event.start.toFormTime(zone).repicked( + wallClockTime(date, startMin), + zone, + shownAt = wallClockTime(drawn.day, drawn.startMin), + notAfter = event.end, + ).instantIn(zone) } else { event.start }, end = if (edge == ResizeEdge.Bottom) { - wallClockInstant(date, startMin + spanMin, zone, kept = endKept ?: startKept) + event.end.toFormTime(zone).repicked( + wallClockTime(date, startMin + spanMin), + zone, + shownAt = wallClockTime(drawn.day, drawn.endMin), + notBefore = event.start, + ).instantIn(zone) } else { event.end }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 345a981..c191839 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -367,7 +367,7 @@ private fun EventDetailContent( val gap = 12.dp // "When" — date/all-day plus the time range. - val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale) + val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale) DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) { Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium) if (whenSecondary != null) { @@ -407,7 +407,7 @@ private fun EventDetailContent( // in the primary instead, so fall back to it. If the zone can't be // read back at all, drop to just the abbreviation + offset. val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull() - ?.let { formatWhen(instance, it, locale) } + ?.let { rememberWhen(instance, it, locale) } ?.let { (primary, secondary) -> secondary ?: primary } Spacer(Modifier.height(2.dp)) Text( @@ -777,18 +777,28 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString { * single primary line spanning both ends. */ @Composable +private fun rememberWhen(instance: EventInstance, zone: TimeZone, locale: Locale): Pair { + val use24Hour = LocalUse24HourFormat.current + val allDayLabel = stringResource(R.string.event_detail_all_day) + // Keyed: a repeated-hour time looks its zone's abbreviation up (#387). + return remember(instance, zone, locale, use24Hour, allDayLabel) { + formatWhen(instance, zone, locale, use24Hour, allDayLabel) + } +} + private fun formatWhen( instance: EventInstance, zone: TimeZone, locale: Locale, + use24Hour: Boolean, + allDayLabel: String, ): Pair { val zid = ZoneId.of(zone.id) val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale) val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) - val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale) + val timeShort = timeOfDayFormatter(use24Hour, locale) val startLdt = instance.start.toJavaLocalDateTime(zid) - val allDayLabel = stringResource(R.string.event_detail_all_day) if (instance.isAllDay) { // All-day events live at UTC midnights with an exclusive end. Resolve diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 4fba20f..63e6153 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -205,7 +205,7 @@ fun EventEditScreen( onSaved: () -> Unit, editKey: LongArray? = null, initialStartMinutes: Int? = null, - initialDurationMinutes: Int? = null, + initialDraft: LongArray? = null, initialForm: EventForm? = null, initialFormSource: ImportSource = ImportSource.File, onManageCalendars: (() -> Unit)? = null, @@ -222,10 +222,15 @@ fun EventEditScreen( beginMillis = editKey[1], endMillis = editKey[2], ) + // A block drafted on the timeline (#153): its own start and end. + initialDraft != null -> viewModel.openDraft( + start = Instant.fromEpochMilliseconds(initialDraft[0]), + end = Instant.fromEpochMilliseconds(initialDraft[1]), + ) else -> { val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - viewModel.openNew(date, initialStartMinutes, initialDurationMinutes) + viewModel.openNew(date, initialStartMinutes) } } } 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 9a01ec9..9774c4e 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 @@ -22,12 +22,12 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.endInstant import de.jeanlucmakiola.calendula.domain.nextFullHour -import de.jeanlucmakiola.calendula.domain.instantIn import de.jeanlucmakiola.calendula.domain.toFormTime -import de.jeanlucmakiola.calendula.domain.FormTime import de.jeanlucmakiola.calendula.domain.resolvedZone import de.jeanlucmakiola.calendula.domain.startInstant import de.jeanlucmakiola.calendula.domain.withEnd +import de.jeanlucmakiola.calendula.domain.withoutKeptPasses +import de.jeanlucmakiola.calendula.domain.withStart import de.jeanlucmakiola.calendula.domain.withTimes import de.jeanlucmakiola.calendula.domain.withTimezone import de.jeanlucmakiola.calendula.domain.EventFormField @@ -65,6 +65,7 @@ import kotlinx.datetime.toLocalDateTime import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant import javax.inject.Inject private const val TAG = "EventEdit" @@ -342,14 +343,10 @@ class EventEditViewModel @Inject constructor( * in the day/week grid; without it the default is the next full hour (today) * or 09:00 (any other day). The event opens an hour long and is stretched to * the configured default by [applyDefaultDuration], like the reminders. - * A block drafted on the timeline (#153) passes its length as - * [durationMinutes] instead: a real length, which a wall-clock end can't - * give across a DST change or a start pushed out of the spring-forward gap. - * A start in the fall-back hour's repeat takes the earlier pass. * No-op when a form is already open, so user input survives configuration * changes; [reset] clears it when the screen closes. */ - fun openNew(date: LocalDate, startMinutes: Int? = null, durationMinutes: Int? = null) { + fun openNew(date: LocalDate, startMinutes: Int? = null) { if (_form.value != null) return val zone = deviceZone() val now = Clock.System.now() @@ -359,10 +356,24 @@ class EventEditViewModel @Inject constructor( date == now.toLocalDateTime(zone).date -> nextFullHour(now) else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone) } - val chosen = durationMinutes?.takeIf { startMinutes != null && it > 0 } - val endAt = startAt + (chosen ?: SettingsPrefs.DEFAULT_EVENT_DURATION).minutes + val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes _form.value = EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone)) - if (chosen != null) _durationTouched.value = true + applyDefaultReminder() + applyDefaultDuration() + } + + /** + * Initialise a fresh form for a block drafted on the timeline (#153), from + * [start] to [end] — the instants it shows, so a pass of the fall-back hour + * or a start pushed out of the spring-forward gap carries over as drawn + * (#387). Its length is the user's pick, so the default length leaves it be. + * No-op when a form is already open, as [openNew]. + */ + fun openDraft(start: Instant, end: Instant) { + if (_form.value != null) return + val zone = deviceZone() + _form.value = EventForm(calendarId = null, start = start.toFormTime(zone), end = end.toFormTime(zone)) + _durationTouched.value = true applyDefaultReminder() applyDefaultDuration() } @@ -589,11 +600,7 @@ class EventEditViewModel @Inject constructor( // which is the same thing a fresh event gets. // An all-day form's times are bare dates, so a kept pass goes with it. update { - if (value) { - it.copy(isAllDay = true, timezone = null, start = FormTime(it.start.dateTime), end = FormTime(it.end.dateTime)) - } else { - it.copy(isAllDay = false) - } + if (value) it.copy(isAllDay = true, timezone = null).withoutKeptPasses() else it.copy(isAllDay = false) } // The default reminder differs for all-day vs timed; re-apply the // type-appropriate default unless the user has hand-edited it (guarded). @@ -687,12 +694,12 @@ class EventEditViewModel @Inject constructor( // default duration, so a later calendar switch keeps their span (#54). fun setEndDate(date: LocalDate) { _durationTouched.value = true - update { it.withEnd(LocalDateTime(date, it.end.time)) } + moveEnd { LocalDateTime(date, it.time) } } fun setEndTime(time: LocalTime) { _durationTouched.value = true - update { it.withEnd(LocalDateTime(it.end.date, time)) } + moveEnd { LocalDateTime(it.date, time) } } /** @@ -834,15 +841,20 @@ class EventEditViewModel @Inject constructor( } private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form -> - val zone = form.resolvedZone(deviceZone()) - val duration = form.endInstant(zone) - form.startInstant(zone) - // A picked time can't say which pass of a repeated hour it means; only - // an unchanged start keeps its own (see [withEnd]). - val picked = transform(form.start.dateTime) - val newStart = (if (picked == form.start.dateTime) form.start else FormTime(picked)).instantIn(zone) - form.withTimes(newStart, newStart + duration, zone) + form.withStart(transform(form.start.dateTime), form.resolvedZone(deviceZone()), opened(form)?.start) } + private fun moveEnd(transform: (LocalDateTime) -> LocalDateTime) = update { form -> + form.withEnd(transform(form.end.dateTime), form.resolvedZone(deviceZone()), opened(form)?.end) + } + + /** + * The event as it was opened, while its times still mean the same instants: + * picking one of them again keeps its pass of a repeated hour (#387). + */ + private fun opened(form: EventForm): EventForm? = + _editTarget.value?.original?.takeIf { it.timezone == form.timezone && !it.isAllDay } + private inline fun update(block: (EventForm) -> EventForm) { _form.value = _form.value?.let(block) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 11deb6c..fbc655a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -100,6 +100,7 @@ import androidx.compose.runtime.SideEffect import de.jeanlucmakiola.calendula.ui.common.laneSlot import de.jeanlucmakiola.calendula.ui.common.LaneColumn import kotlin.math.roundToInt +import kotlin.time.Instant import androidx.compose.ui.unit.Constraints import androidx.compose.ui.layout.Layout import androidx.compose.ui.draw.alpha @@ -249,7 +250,7 @@ fun TimelineScreen( onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - onCreateSpan: (LocalDate, Int, Int?) -> Unit, + onCreateDraft: (Instant, Instant) -> Unit, modifier: Modifier = Modifier, weekViewModel: WeekViewModel = hiltViewModel(), multiDayViewModel: MultiDayViewModel = hiltViewModel(), @@ -264,7 +265,7 @@ fun TimelineScreen( onOpenDay = onOpenDay, onEventClick = onEventClick, onCreateEvent = onCreateEvent, - onCreateSpan = onCreateSpan, + onCreateDraft = onCreateDraft, modifier = modifier, ) } @@ -328,7 +329,7 @@ private fun PagedTimelineScreen( onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - onCreateSpan: (LocalDate, Int, Int?) -> Unit, + onCreateDraft: (Instant, Instant) -> Unit, modifier: Modifier, ) { // The view on screen, which trails [view] while the columns resize into it. @@ -514,7 +515,7 @@ private fun PagedTimelineScreen( onEventClick = onEventClick, onOpenDay = onOpenDay, onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - onCreateSpan = onCreateSpan, + onCreateDraft = onCreateDraft, draftMinutes = draftMinutes, modifier = Modifier .padding(innerPadding) @@ -538,7 +539,7 @@ private fun WeekContent( onEventClick: (EventInstance) -> Unit, onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, - onCreateSpan: (LocalDate, Int, Int?) -> Unit, + onCreateDraft: (Instant, Instant) -> Unit, draftMinutes: Int, modifier: Modifier = Modifier, ) { @@ -597,12 +598,11 @@ private fun WeekContent( ) } - // A drafted block (#153) opens the new-event form at its start, for its real - // length: the end it is drawn to differs on a DST change (#387). + // A drafted block (#153) opens the new-event form at its own instants: the + // end it is drawn to differs on a DST change (#387). val createDraft: (EventInstance) -> Unit = { draft -> dragController.deselect() - val start = draft.start.toLocalDateTime(zone) - onCreateSpan(start.date, start.time.toSecondOfDay() / 60, (draft.end - draft.start).inWholeMinutes.toInt()) + onCreateDraft(draft.start, draft.end) } // Selection (#141) ends on back, a page or view change, or any tap. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 267d2ff..cd26c22 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -34,7 +34,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.FixedOffsetTimeZone import kotlinx.datetime.TimeZone +import kotlinx.datetime.toKotlinUtcOffset import kotlinx.datetime.toJavaZoneId import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime @@ -45,6 +47,8 @@ import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import java.util.Locale import kotlin.time.Instant +import kotlin.time.toKotlinDuration +import kotlin.time.toKotlinInstant import javax.inject.Inject import kotlin.math.abs @@ -358,9 +362,9 @@ internal fun wallClockMinutes(instant: Instant, day: LocalDate, zone: TimeZone): /** * Where a run from [start] to [end] that begins at [startMin] ends on [day]'s - * wall clock. One ending in the fall-back hour's repeat can read as ending before - * it began: it starts in that hour's first pass, so it covers the hour's wall - * clock from its start to where the hour ends, and is drawn to there. + * wall clock. One ending in the fall-back hour's repeat that began before it + * covers the hour's wall clock from its start to where the hour ends, and is + * drawn to there: its own wall-clock end reads earlier than its length (#387). */ internal fun wallClockEndMinutes( startMin: Int, @@ -370,23 +374,25 @@ internal fun wallClockEndMinutes( zone: TimeZone, ): Int { val wall = end.minutesPast(day, zone) - if (wall > startMin || end <= start) return maxOf(wall, startMin) - return repeatedHourEndMinutes(end, day, zone) ?: (startMin + (end - start).inWholeMinutes.toInt()) + if (end <= start) return maxOf(wall, startMin) + val drawn = repeatedHourEndMinutes(start, end, day, zone) ?: wall + return if (drawn > startMin) drawn else startMin + (end - start).inWholeMinutes.toInt() } /** - * Where the repeated hour [end] falls in ends, as minutes past [day]'s midnight: - * the wall clock the zone fell back from (03:00 in Berlin). Null when the last - * change before [end] was no fall-back. + * Where the fall-back hour's repeat [end] falls in ends, as minutes past [day]'s + * midnight: the wall clock the zone fell back from (03:00 in Berlin). Null unless + * [end] is in that repeat and [start] before it. */ -private fun repeatedHourEndMinutes(end: Instant, day: LocalDate, zone: TimeZone): Int? { +private fun repeatedHourEndMinutes(start: Instant, end: Instant, day: LocalDate, zone: TimeZone): Int? { // Just past [end], so an end on the transition itself still finds it. val after = java.time.Instant.ofEpochMilli(end.toEpochMilliseconds() + 1) val transition = zone.toJavaZoneId().rules.previousTransition(after) ?.takeIf { it.isOverlap } ?: return null - val local = transition.dateTimeBefore - val days = (local.toLocalDate().toEpochDay() - day.toEpochDays()).toInt() - return days * MINUTES_PER_DAY + local.toLocalTime().toSecondOfDay() / 60 + val fellBack = transition.instant.toKotlinInstant() + if (start >= fellBack || end >= fellBack + transition.duration.abs().toKotlinDuration()) return null + // The moment it fell back, read on the clock it fell back from. + return fellBack.minutesPast(day, FixedOffsetTimeZone(transition.offsetBefore.toKotlinUtcOffset())) } /** diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index 8001b5a..0d39262 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -413,17 +413,63 @@ class EventFormTest { } @Test - fun `an end picked by hand takes the earlier repeated hour`() { + fun `an end picked in the repeated hour moves the way the hand did`() { + // 02:45 CEST to 02:15 CET. val form = form().withTimes( Instant.parse("2026-10-25T00:45:00Z"), Instant.parse("2026-10-25T01:15:00Z"), berlin, ) // Picking the end it already has keeps its pass. - assertThat(form.withEnd(form.end.dateTime)).isEqualTo(form) - val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50))) - assertThat(picked.end.offset).isNull() - assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:50:00Z")) + assertThat(form.withEnd(form.end.dateTime, berlin)).isEqualTo(form) + // Later on the clock: later in time, still on the second pass. + assertThat(form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50)), berlin).endInstant(berlin)) + .isEqualTo(Instant.parse("2026-10-25T01:50:00Z")) + // Earlier on the clock: earlier in time, not an hour earlier. + assertThat(form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 5)), berlin).endInstant(berlin)) + .isEqualTo(Instant.parse("2026-10-25T01:05:00Z")) + } + + @Test + fun `an end picked in the repeated hour stays after a second-pass start`() { + // 02:10 CET to 04:00, its end set to 02:40: only the second pass is after the start. + val form = form().withTimes( + Instant.parse("2026-10-25T01:10:00Z"), + Instant.parse("2026-10-25T03:00:00Z"), + berlin, + ) + val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 40)), berlin) + assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:40:00Z")) + assertThat(picked.problems(berlin)).isEmpty() + } + + @Test + fun `a repeated-hour end changed and changed back is the end it was`() { + // 01:00 CEST to 02:30 CEST, its end set to 03:00 and back. + val original = form().withTimes( + Instant.parse("2026-10-24T23:00:00Z"), + Instant.parse("2026-10-25T00:30:00Z"), + berlin, + ) + val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)) + val away = original.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(3, 0)), berlin) + assertThat(away.withEnd(repeated, berlin, original = original.end)).isEqualTo(original) + // Without the opened end to go by, coming back from 03:00 reads as the later pass. + assertThat(away.withEnd(repeated, berlin).endInstant(berlin)) + .isEqualTo(Instant.parse("2026-10-25T01:30:00Z")) + } + + @Test + fun `a start picked in the repeated hour keeps the length`() { + // 02:30 CET to 03:30, its start set back to 02:10. + val form = form().withTimes( + Instant.parse("2026-10-25T01:30:00Z"), + Instant.parse("2026-10-25T02:30:00Z"), + berlin, + ) + val picked = form.withStart(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 10)), berlin) + assertThat(picked.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:10:00Z")) + assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T02:10:00Z")) } @Test @@ -494,8 +540,8 @@ class EventFormTest { berlin, ) val back = original - .withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))) - .withEnd(original.end.dateTime) + .withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)), berlin) + .withEnd(original.end.dateTime, berlin) assertThat(back).isEqualTo(original) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt index cffdf5c..0d293b5 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt @@ -265,4 +265,15 @@ class TimelineDropTest { assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z")) assertThat(drop.end).isEqualTo(event.end) } + + @Test + fun `a bottom resize up the repeated hour shortens the event`() { + // 02:20 CEST to 02:10 CET is drawn 02:20 to 03:00; its end pulled up to 02:50. + val event = overnight.copy( + start = Instant.parse("2026-10-25T00:20:00Z"), + end = Instant.parse("2026-10-25T01:10:00Z"), + ) + val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels) + assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z")) + } } 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 4e35d6c..c3b6721 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 @@ -64,11 +64,16 @@ class EventEditViewModelTest { syncsEvents = syncsEvents, ) - private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail( + private fun detail( + calendarId: Long, + rrule: String? = null, + start: Instant = Instant.fromEpochMilliseconds(beginMillis), + end: Instant = Instant.fromEpochMilliseconds(endMillis), + ): EventDetail = EventDetail( instance = EventInstance( instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup", - start = Instant.fromEpochMilliseconds(beginMillis), - end = Instant.fromEpochMilliseconds(endMillis), + start = start, + end = end, isAllDay = false, color = 0xFF000000.toInt(), location = null, ), description = null, organizer = null, attendees = emptyList(), rrule = rrule, @@ -414,7 +419,7 @@ class EventEditViewModelTest { } @Test - fun `a drafted block opens at its real length across the fall-back hour`( + fun `a drafted block opens at its own instants across the fall-back hour`( @TempDir tempDir: Path, ) = runTest(dispatcher) { val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) } @@ -422,7 +427,7 @@ class EventEditViewModelTest { val job = activate(vm) // An hour from 02:30 CEST ends at 02:30 CET, the same wall clock. - vm.openNew(LocalDate(2026, 10, 25), startMinutes = 2 * 60 + 30, durationMinutes = 60) + vm.openDraft(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z")) advanceUntilIdle() val form = vm.state.value!!.form!! @@ -433,21 +438,46 @@ class EventEditViewModelTest { } @Test - fun `a drafted block in the spring-forward gap keeps its length`( + fun `a drafted block on the second pass stays on it at its length`( @TempDir tempDir: Path, ) = runTest(dispatcher) { val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) } - val vm = viewModel(tempDir, fake).apply { deviceZone = { berlin } } + val s = settings(tempDir) + s.setCalendarEventDuration(1L, 480) + val vm = viewModel(tempDir, fake, s).apply { deviceZone = { berlin } } val job = activate(vm) - // 02:30 doesn't exist on 29 March: the start moves on to 03:30, the end with it. - vm.openNew(LocalDate(2026, 3, 29), startMinutes = 2 * 60 + 30, durationMinutes = 30) + // 02:30 CET to 03:00 CET: the default length mustn't stretch it. + vm.openDraft(Instant.parse("2026-10-25T01:30:00Z"), Instant.parse("2026-10-25T02:00:00Z")) advanceUntilIdle() val form = vm.state.value!!.form!! - assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 3, 29, 3, 30)) - assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 3, 29, 4, 0)) - assertThat(form.problems(berlin)).isEmpty() + assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z")) + assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T02:00:00Z")) + job.cancel() + } + + @Test + fun `a time picked in the repeated hour and back keeps the opened pass`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + // Opened at 02:30 CET (the second pass) to 03:30, its start moved away and back. + val begin = Instant.parse("2026-10-25T01:30:00Z") + val end = Instant.parse("2026-10-25T02:30:00Z") + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L)) + eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) } + } + val vm = viewModel(tempDir, fake).apply { deviceZone = { berlin } } + val job = activate(vm) + vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds()) + advanceUntilIdle() + val opened = vm.state.value!!.form!! + + vm.setStartTime(LocalTime(4, 0)) + vm.setStartTime(LocalTime(2, 30)) + + assertThat(vm.state.value!!.form).isEqualTo(opened) job.cancel() } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index 94553bb..6cddb75 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -294,4 +294,17 @@ class WeekLayoutTest { } private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY) + + @Test + fun `a longer run into the repeated fall-back hour is drawn no shorter`() { + val day = LocalDate(2026, 10, 25) + // 02:10 CEST to 02:05 CET (55 min) and to 02:20 CET (70 min): both to 03:00. + val shorter = event(Instant.parse("2026-10-25T00:10:00Z"), Instant.parse("2026-10-25T01:05:00Z")) + val longer = event(Instant.parse("2026-10-25T00:10:00Z"), Instant.parse("2026-10-25T01:20:00Z"), id = 2L) + assertThat(layoutDay(listOf(shorter), day, brussels).single().endMin).isEqualTo(3 * 60) + assertThat(layoutDay(listOf(longer), day, brussels).single().endMin).isEqualTo(3 * 60) + // A run that starts on the second pass reads its plain wall clock. + val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z")) + assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40) + } }