Fix DST edge cases in resize, drafts and the event form (#387)
- A resized edge keeps its own pass of the repeated hour, else the other edge's, so an end pulled into the hour an event started in its second pass no longer lands before the start. - A drafted block opens the form at its real length rather than the end it is drawn to, so a draft ending in the repeated hour keeps its length and one starting in the spring-forward gap no longer ends before it starts. - A form only keeps an offset for a time in the repeated hour, so a day move doesn't depend on the season of the occurrence it came from and a time changed and changed back compares equal again. - Changing the form's zone, or going all-day, drops a kept pass. - A drag works out the event's pass once instead of every frame. - The form caches the pinned-zone check and the time labels again. - Tests cover the pinned event's local times again.
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -239,16 +239,16 @@ fun CalendarHost(
|
||||
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
|
||||
// A drafted block's end (#153), in minutes from the start day's midnight.
|
||||
var createEndMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var heldCreateEndMinutes by remember { mutableStateOf<Int?>(null) }
|
||||
val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, endMinutes ->
|
||||
// A drafted block's length (#153), in minutes.
|
||||
var createDurationMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var heldCreateDurationMinutes by remember { mutableStateOf<Int?>(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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user