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) + } }