diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index ac2bec3..2a4b75a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -5,7 +5,6 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.UtcOffset -import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant @@ -38,9 +37,10 @@ data class EventForm( */ val timezone: String? = null, /** - * The UTC offsets [start]/[end] were read at, when they came from real - * instants. Only a wall-clock time the fall-back hour repeats needs them: - * both of its instants share one [LocalDateTime] (#387). + * The UTC offsets [start]/[end] were read at, set only when the fall-back + * hour repeats that wall clock: both of its instants share one + * [LocalDateTime] (#387). Null everywhere else, so a time changed and changed + * back compares equal again. See [repeatedHourOffsetAt]. */ val startOffset: UtcOffset? = null, val endOffset: UtcOffset? = null, @@ -170,8 +170,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): start = start, end = end, timezone = pinnedZone, - startOffset = if (instance.isAllDay) null else formZone.offsetAt(begin), - endOffset = if (instance.isAllDay) null else formZone.offsetAt(finish), + startOffset = if (instance.isAllDay) null else formZone.repeatedHourOffsetAt(begin), + endOffset = if (instance.isAllDay) null else formZone.repeatedHourOffsetAt(finish), location = instance.location.orEmpty(), description = description.orEmpty(), reminders = reminders.map { it.minutes }.distinct().sorted(), 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 bb0db96..919fa4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -34,6 +34,13 @@ fun Instant.inRepeatedHour(zone: TimeZone): Boolean { return zid.rules.getValidOffsets(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid)).size > 1 } +/** + * [at]'s offset in this zone when its wall clock is one the fall-back hour + * repeats, else null: elsewhere the wall clock alone already names the instant. + * What [EventForm.startOffset]/[EventForm.endOffset] hold (#387). + */ +fun TimeZone.repeatedHourOffsetAt(at: Instant): UtcOffset? = if (at.inRepeatedHour(this)) offsetAt(at) else null + /** The form's start as an instant in [zone], keeping its [EventForm.startOffset]. */ fun EventForm.startInstant(zone: TimeZone): Instant = start.resolveIn(zone, startOffset) @@ -44,10 +51,20 @@ fun EventForm.endInstant(zone: TimeZone): Instant = end.resolveIn(zone, endOffse fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm = copy( start = start.toLocalDateTime(zone), end = end.toLocalDateTime(zone), - startOffset = zone.offsetAt(start), - endOffset = zone.offsetAt(end), + startOffset = zone.repeatedHourOffsetAt(start), + endOffset = zone.repeatedHourOffsetAt(end), ) +/** + * The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour + * was one of the old zone's, so it goes when the zone the times mean changes. + */ +fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm { + val pinned = copy(timezone = zoneId) + if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned + return pinned.copy(startOffset = null, endOffset = null) +} + /** * 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 drops [EventForm.endOffset] and takes diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt index e6a40c8..8b407f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt @@ -4,7 +4,6 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.hours import kotlin.time.Instant @@ -64,8 +63,8 @@ fun buildInsertEventForm( isAllDay = isAllDay, start = start, end = end, - startOffset = timed?.let { zone.offsetAt(it.first) }, - endOffset = timed?.let { zone.offsetAt(it.second) }, + startOffset = timed?.let { zone.repeatedHourOffsetAt(it.first) }, + endOffset = timed?.let { zone.repeatedHourOffsetAt(it.second) }, location = location.orEmpty(), description = description.orEmpty(), // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" @@ -74,7 +73,8 @@ fun buildInsertEventForm( ) } -private fun nextFullHour(now: Instant): Instant { +/** The first full hour after [now]: may roll into the next day before midnight. */ +internal fun nextFullHour(now: Instant): Instant { val hourMillis = 3_600_000L return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt index 4263c78..5560365 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -1,11 +1,11 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime /** @@ -35,8 +35,8 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { isAllDay = isAllDay, start = start, end = end, - startOffset = if (isAllDay) null else zone.offsetAt(this.start), - endOffset = if (isAllDay) null else zone.offsetAt(this.end), + startOffset = if (isAllDay) null else zone.repeatedHourOffsetAt(this.start), + endOffset = if (isAllDay) null else zone.repeatedHourOffsetAt(this.end), location = location.orEmpty(), description = description.orEmpty(), reminders = semanticReminderMinutes(), 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 704481f..2b62c13 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -239,16 +239,16 @@ fun CalendarHost( var heldCreateIso by remember { mutableStateOf(null) } var createStartMinutes by rememberSaveable { mutableStateOf(null) } var heldCreateMinutes by remember { mutableStateOf(null) } - // A drafted block's end (#153), in minutes from the start day's midnight. - var createEndMinutes by rememberSaveable { mutableStateOf(null) } - var heldCreateEndMinutes by remember { mutableStateOf(null) } - val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, endMinutes -> + // 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 -> heldCreateIso = date.toString() createDateIso = date.toString() heldCreateMinutes = startMinutes createStartMinutes = startMinutes - heldCreateEndMinutes = endMinutes - createEndMinutes = endMinutes + heldCreateDurationMinutes = durationMinutes + createDurationMinutes = durationMinutes } val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> onCreateSpan(date, startMinutes, null) } @@ -386,8 +386,8 @@ fun CalendarHost( createDateIso = iso heldCreateMinutes = null createStartMinutes = null - heldCreateEndMinutes = null - createEndMinutes = null + heldCreateDurationMinutes = null + createDurationMinutes = null onWidgetNavConsumed() } null -> {} @@ -680,7 +680,7 @@ fun CalendarHost( EventEditScreen( initialDateIso = iso, initialStartMinutes = createStartMinutes ?: heldCreateMinutes, - initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes, + initialDurationMinutes = createDurationMinutes ?: heldCreateDurationMinutes, onClose = { createDateIso = null }, onSaved = { createDateIso = null }, onManageCalendars = { showCalendars = true }, 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 bebd762..9afa7eb 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,7 @@ 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.inRepeatedHour +import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt import de.jeanlucmakiola.calendula.domain.resolveIn import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY import de.jeanlucmakiola.calendula.ui.week.TimedBlock @@ -60,7 +60,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.offsetAt +import kotlinx.datetime.UtcOffset import kotlinx.datetime.plus import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -319,6 +319,39 @@ enum class ResizeEdge { Top, Bottom } /** Where a finished resize asks its event to begin and end. */ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant) +/** + * [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. + */ +internal fun resizedDrop( + event: EventInstance, + edge: ResizeEdge?, + date: LocalDate, + startMin: Int, + spanMin: Int, + zone: TimeZone, +): TimelineResizeDrop { + val startKept = zone.repeatedHourOffsetAt(event.start) + val endKept = zone.repeatedHourOffsetAt(event.end) + return TimelineResizeDrop( + event = event, + start = if (edge == ResizeEdge.Top) { + wallClockInstant(date, startMin, zone, kept = startKept ?: endKept) + } else { + event.start + }, + end = if (edge == ResizeEdge.Bottom) { + wallClockInstant(date, startMin + spanMin, zone, kept = endKept ?: startKept) + } else { + event.end + }, + ) +} + /** * The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the * start day's midnight. Snapped, and at least one snap step long. @@ -556,6 +589,8 @@ class TimelineDragController { private var clipOffsetMin = 0 private var dragZone: TimeZone? = null private var dragDuration = Duration.ZERO + /** The dragged event's pass of a repeated hour, worked out once per drag (#387). */ + private var dragKept: UtcOffset? = null /** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */ private var titleLines = 0 @@ -600,6 +635,7 @@ class TimelineDragController { this.clipOffsetMin = clipOffsetMin dragZone = zone dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO) + dragKept = zone.repeatedHourOffsetAt(block.event.start) this.titleLines = titleLines settling = null isDragging = true @@ -638,6 +674,7 @@ class TimelineDragController { clipOffsetMin = 0 dragZone = null dragDuration = Duration.ZERO + dragKept = null titleLines = 0 isDragging = false pointerMoved = false @@ -765,27 +802,7 @@ class TimelineDragController { val edge = resizeEdge cancel() if (landed == null || landed.slot == origin) return null - // 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). - val drop = TimelineResizeDrop( - event = landed.event, - start = if (edge == ResizeEdge.Top) { - wallClockInstant(landed.date, landed.eventStartMin, zone, keepOffsetOf = landed.event.start) - } else { - landed.event.start - }, - end = if (edge == ResizeEdge.Bottom) { - wallClockInstant( - landed.date, - landed.eventStartMin + landed.eventSpanMin, - zone, - keepOffsetOf = landed.event.end, - ) - } else { - landed.event.end - }, - ) + val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) settlingSelected = selection?.event?.instanceId == landed.event.instanceId settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed liftedInstanceId = landed.event.instanceId @@ -917,7 +934,7 @@ class TimelineDragController { val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val target = days[dayIndex] - val keep = block.event.start + val keep = dragKept // Measured where it would land: the drop keeps the real duration, which in // wall clock gains or loses the hour of a DST change it crosses (#387). val floor = dragFloorMin( @@ -1038,7 +1055,7 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean = * visibly settles there. */ fun TimelineDrop.startInstant(zone: TimeZone): Instant = - wallClockInstant(date, startMin - clipOffsetMin, zone, keepOffsetOf = event.start) + wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start)) /** * The wall-clock minutes an event lasting [duration] covers when it starts @@ -1050,28 +1067,22 @@ internal fun wallClockSpanMinutes( startMin: Int, duration: Duration, zone: TimeZone, - keepOffsetOf: Instant? = null, + kept: UtcOffset? = null, ): Int { - val start = wallClockInstant(date, startMin, zone, keepOffsetOf) + val start = wallClockInstant(date, startMin, zone, kept) return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin } /** * [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] * (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless - * [keepOffsetOf] is itself in that hour's later one: an event there stays on it - * (#387). An instant outside the hour says nothing about which pass the grid - * slot means, so a drop never depends on the season the event came from. + * [kept] is its later one: an event there stays on it (#387). Pass + * [repeatedHourOffsetAt] of the event's time, which is null outside the hour — + * such an instant says nothing about which pass the grid slot means, so a drop + * never depends on the season the event came from. */ -fun wallClockInstant( - date: LocalDate, - minutes: Int, - zone: TimeZone, - keepOffsetOf: Instant? = null, -): Instant { - val kept = keepOffsetOf?.takeIf { it.inRepeatedHour(zone) }?.let(zone::offsetAt) - return wallClockTime(date, minutes).resolveIn(zone, kept) -} +fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone, kept: UtcOffset? = null): Instant = + wallClockTime(date, minutes).resolveIn(zone, kept) /** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */ fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime { 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 4d1a14b..0298958 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, - initialEndMinutes: Int? = null, + initialDurationMinutes: Int? = null, initialForm: EventForm? = null, initialFormSource: ImportSource = ImportSource.File, onManageCalendars: (() -> Unit)? = null, @@ -225,7 +225,7 @@ fun EventEditScreen( else -> { val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - viewModel.openNew(date, initialStartMinutes, initialEndMinutes) + viewModel.openNew(date, initialStartMinutes, initialDurationMinutes) } } } @@ -706,7 +706,13 @@ private fun EventEditContent( // whoever is reading it. Spell the local equivalent out rather than // leaving the user to do the offset arithmetic. Absent unless the // event is pinned somewhere other than here. - if (form.isPinnedAwayFrom(TimeZone.currentSystemDefault())) { + // Keyed rather than recomputed: this sits in the same Column as the + // title field, so it would otherwise re-parse the zone on every + // keystroke. + val pinnedAway = remember(form.timezone, form.isAllDay) { + form.isPinnedAwayFrom(TimeZone.currentSystemDefault()) + } + if (pinnedAway) { Spacer(Modifier.height(2.dp)) Text( text = stringResource( @@ -2264,14 +2270,16 @@ private fun EditCard( @Composable private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String { val use24Hour = LocalUse24HourFormat.current - val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) } - fun format(at: Instant) = withRepeatedHourZone( - timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()), - at, - zone, - locale, - ) - return if (start == end) format(start) else "${format(start)} – ${format(end)}" + return remember(start, end, zone, locale, use24Hour) { + val timeFormat = timeOfDayFormatter(use24Hour, locale) + fun format(at: Instant) = withRepeatedHourZone( + timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()), + at, + zone, + locale, + ) + if (start == end) format(start) else "${format(start)} – ${format(end)}" + } } /** @@ -2396,8 +2404,11 @@ private fun ScheduleRow( .padding(vertical = 8.dp, horizontal = 6.dp), ) if (!isAllDay) { + val time = remember(dateTime, instant, zone, locale, timeFormat) { + withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale) + } Text( - text = withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale), + text = time, style = MaterialTheme.typography.titleMedium, color = valueColor, modifier = Modifier 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 e124d0a..be843e3 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 @@ -20,11 +20,13 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee 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.resolveIn import de.jeanlucmakiola.calendula.domain.resolvedZone import de.jeanlucmakiola.calendula.domain.startInstant import de.jeanlucmakiola.calendula.domain.withEnd import de.jeanlucmakiola.calendula.domain.withTimes +import de.jeanlucmakiola.calendula.domain.withTimezone import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.isEventTarget @@ -32,7 +34,6 @@ import de.jeanlucmakiola.calendula.domain.populatedFields import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.toEditSnapshot import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME -import de.jeanlucmakiola.calendula.ui.common.wallClockTime import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -56,13 +57,11 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.offsetAt import kotlinx.datetime.toInstant 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" @@ -336,36 +335,28 @@ 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 [endMinutes] instead, - * counted from [date]'s midnight and past 1440 for a later day. + * 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, endMinutes: Int? = null) { + fun openNew(date: LocalDate, startMinutes: Int? = null, durationMinutes: Int? = null) { if (_form.value != null) return val zone = TimeZone.currentSystemDefault() val now = Clock.System.now() val startAt = when { startMinutes != null -> LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone) - date == now.toLocalDateTime(zone).date -> { - // Today: the next full hour (may roll into tomorrow before midnight). - val hourMillis = 3_600_000L - Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis) - } + date == now.toLocalDateTime(zone).date -> nextFullHour(now) else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone) } - val chosenEnd = endMinutes?.takeIf { startMinutes != null && it > startMinutes } - ?.let { wallClockTime(date, it).resolveIn(zone, zone.offsetAt(startAt)) } - val endAt = chosenEnd ?: (startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes) - _form.value = EventForm( - calendarId = null, - start = startAt.toLocalDateTime(zone), - end = endAt.toLocalDateTime(zone), - startOffset = zone.offsetAt(startAt), - endOffset = zone.offsetAt(endAt), - ) - if (chosenEnd != null) _durationTouched.value = true + val chosen = durationMinutes?.takeIf { startMinutes != null && it > 0 } + val endAt = startAt + (chosen ?: SettingsPrefs.DEFAULT_EVENT_DURATION).minutes + val start = startAt.toLocalDateTime(zone) + _form.value = EventForm(calendarId = null, start = start, end = start).withTimes(startAt, endAt, zone) + if (chosen != null) _durationTouched.value = true applyDefaultReminder() applyDefaultDuration() } @@ -590,7 +581,11 @@ class EventEditViewModel @Inject constructor( // the provider stores UTC-anchored, so a zone could only misrepresent // them. Coming back out of all-day leaves the event on the device zone, // which is the same thing a fresh event gets. - update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) } + // 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, startOffset = null, endOffset = null) + 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). applyDefaultReminder() @@ -605,7 +600,7 @@ class EventEditViewModel @Inject constructor( * time — only real picks are remembered, not the device fallback. */ fun setTimezone(zoneId: String?) { - update { it.copy(timezone = zoneId) } + update { it.withTimezone(zoneId, TimeZone.currentSystemDefault()) } if (zoneId != null) { viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } } } 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 134c4cf..11deb6c 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 @@ -74,7 +74,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges import de.jeanlucmakiola.calendula.ui.common.wallClockInstant -import de.jeanlucmakiola.calendula.ui.common.wallClockSpan import de.jeanlucmakiola.calendula.ui.common.PickUp import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance import de.jeanlucmakiola.calendula.ui.common.ResizeEdge @@ -598,11 +597,12 @@ private fun WeekContent( ) } - // A drafted block (#153) opens the new-event form at the span it shows. + // 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). val createDraft: (EventInstance) -> Unit = { draft -> dragController.deselect() - val span = wallClockSpan(draft, zone) - onCreateSpan(span.day, span.startMin, span.endMin) + val start = draft.start.toLocalDateTime(zone) + onCreateSpan(start.date, start.time.toSecondOfDay() / 60, (draft.end - draft.start).inWholeMinutes.toInt()) } // Selection (#141) ends on back, a page or view change, or any tap. 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 a99a8ff..a3f78f8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -5,6 +5,8 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.toLocalDateTime import org.junit.jupiter.api.Test import kotlin.time.Instant @@ -423,4 +425,93 @@ class EventFormTest { assertThat(picked.endOffset).isNull() assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:50:00Z")) } + + @Test + fun `a pinned event's times land in the device zone`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + timezone = "America/New_York", + ) + val zone = form.resolvedZone(berlin) + // 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2). + assertThat(form.startInstant(zone).toLocalDateTime(berlin)) + .isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0))) + assertThat(form.endInstant(zone).toLocalDateTime(berlin)) + .isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0))) + } + + @Test + fun `a pinned event's local time crosses the date line with the offset`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)), + timezone = "America/New_York", + ) + // 20:00 New York is 02:00 the NEXT day in Berlin. + assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin)) + .isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0))) + } + + @Test + fun `a pinned event's local time tracks each zone's own DST`() { + // Late March the US has sprung forward and Europe hasn't: five hours, not six. + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)), + timezone = "America/New_York", + ) + assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin)) + .isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0))) + } + + @Test + fun `offsets are kept only for a repeated hour`() { + val summer = form().withTimes( + Instant.parse("2026-06-11T08:00:00Z"), + Instant.parse("2026-06-11T09:00:00Z"), + berlin, + ) + assertThat(summer.startOffset).isNull() + assertThat(summer.endOffset).isNull() + val repeated = form().withTimes( + Instant.parse("2026-10-25T00:45:00Z"), + Instant.parse("2026-10-25T01:15:00Z"), + berlin, + ) + assertThat(repeated.startOffset).isEqualTo(UtcOffset(hours = 2)) + assertThat(repeated.endOffset).isEqualTo(UtcOffset(hours = 1)) + } + + @Test + fun `an end changed and changed back is the form it was`() { + val original = detail().toEditForm( + Instant.parse("2026-06-11T08:00:00Z").toEpochMilliseconds(), + Instant.parse("2026-06-11T09:00:00Z").toEpochMilliseconds(), + berlin, + ) + val back = original + .withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))) + .withEnd(original.end) + assertThat(back).isEqualTo(original) + } + + @Test + fun `a new zone drops the kept pass, the same zone keeps it`() { + val repeated = form().withTimes( + Instant.parse("2026-10-25T01:30:00Z"), + Instant.parse("2026-10-25T02:00:00Z"), + berlin, + ) + assertThat(repeated.withTimezone("Europe/Berlin", berlin)).isEqualTo(repeated.copy(timezone = "Europe/Berlin")) + val moved = repeated.withTimezone("Africa/Lagos", berlin) + assertThat(moved.startOffset).isNull() + assertThat(moved.endOffset).isNull() + // Back on Berlin, a picked 02:30 takes the earlier pass, as any picked time does. + assertThat(moved.withTimezone(null, berlin).startInstant(berlin)) + .isEqualTo(Instant.parse("2026-10-25T00:30:00Z")) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt index d26a5e3..fb8b8f3 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -232,4 +232,22 @@ class EventShiftTest { val summer = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(14, 0)) assertThat(summer.resolveIn(berlin, cet)).isEqualTo(summer.toInstant(berlin)) } + + @Test + fun `a day move onto the repeated hour doesn't depend on the source's season`() { + // 02:30 a week after the fall-back day (CET) and in summer (CEST), both + // moved onto it: the earlier pass either way, as a timeline drop. + val first = Instant.parse("2026-10-25T00:30:00Z") + val november = form().withTimes( + Instant.parse("2026-11-01T01:30:00Z"), + Instant.parse("2026-11-01T02:30:00Z"), + berlin, + ) + assertThat(november.shiftedByDays(-7, berlin).startInstant(berlin)).isEqualTo(first) + val summer = form( + start = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(2, 30)), + end = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(3, 30)), + ) + assertThat(summer.shiftedByDays(7, berlin).startInstant(berlin)).isEqualTo(first) + } } 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 e9cf383..cffdf5c 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 @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.common import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.layoutDay import de.jeanlucmakiola.calendula.ui.week.minutesPast @@ -216,7 +217,7 @@ class TimelineDropTest { @Test fun `a start in the spring-forward gap resolves an hour on`() { val springForward = LocalDate(2026, 3, 29) - val landed = wallClockInstant(springForward, 2 * 60 + 30, brussels, keepOffsetOf = overnight.start) + val landed = wallClockInstant(springForward, 2 * 60 + 30, brussels, kept = brussels.repeatedHourOffsetAt(overnight.start)) assertThat(landed.minutesPast(springForward, brussels)).isEqualTo(3 * 60 + 30) assertThat(wallClockSpanMinutes(springForward, 3 * 60 + 30, 1.hours, brussels)).isEqualTo(60) } @@ -234,10 +235,34 @@ class TimelineDropTest { val first = Instant.parse("2026-10-25T00:30:00Z") val july = Instant.parse("2026-07-01T10:00:00Z") val november = Instant.parse("2026-11-10T10:00:00Z") - assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = july)).isEqualTo(first) - assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = november)).isEqualTo(first) + assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(july))).isEqualTo(first) + assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(november))).isEqualTo(first) // Later the same day, after the clocks went back: still no say. val evening = Instant.parse("2026-10-25T18:00:00Z") - assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = evening)).isEqualTo(first) + assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(evening))).isEqualTo(first) + } + + @Test + fun `a bottom resize into the repeated hour keeps a second-pass start's pass`() { + // 02:30 CET (the second pass) to 04:00, its end pulled up to 02:45. + val event = overnight.copy( + start = Instant.parse("2026-10-25T01:30:00Z"), + end = Instant.parse("2026-10-25T03:00:00Z"), + ) + val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels) + assertThat(drop.start).isEqualTo(event.start) + assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z")) + } + + @Test + fun `a resized edge keeps its own pass over the other edge's`() { + // 02:30 CEST (the first pass) to 02:15 CET (the second), the start pulled to 02:00. + val event = overnight.copy( + start = Instant.parse("2026-10-25T00:30:00Z"), + end = Instant.parse("2026-10-25T01:15:00Z"), + ) + val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels) + assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z")) + assertThat(drop.end).isEqualTo(event.end) } }