Carry each form time's repeated-hour pass with it (#387)
The event form held its times and their UTC offsets as four separate fields, so any change to a time had to remember to refresh or clear its offset. A form time is now one FormTime: the wall clock plus the offset it was read at when the fall-back hour repeats it. Setting a time sets both, so the two can't drift apart. Wall-clock meaning is unchanged: an unpinned form still follows the device zone, a zone switch keeps the wall clock and all-day forms stay dates. - A pass switch now shows up in the form itself, so saving no longer compares the written times separately. - The editor's view model reads the device zone through a seam, and tests cover a draft opened across the fall-back hour and in the spring-forward gap.
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.resolveIn
|
||||
import de.jeanlucmakiola.calendula.domain.instantIn
|
||||
import kotlinx.datetime.toKotlinTimeZone
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||
@@ -50,8 +51,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
} else {
|
||||
val writeZone = writeZone(zone)
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.resolveIn(writeZone.toKotlinTimeZone(), startOffset).toEpochMilliseconds(),
|
||||
dtEndMillis = end.resolveIn(writeZone.toKotlinTimeZone(), endOffset).toEpochMilliseconds(),
|
||||
dtStartMillis = start.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
|
||||
dtEndMillis = end.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
@@ -77,7 +78,7 @@ private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||
start.date.toJavaLocalDate().atStartOfDay()
|
||||
} else {
|
||||
start.toJavaLocalDateTime()
|
||||
start.dateTime.toJavaLocalDateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,13 +185,12 @@ internal fun buildEventUpdateValues(
|
||||
// A zone change counts as a time change even when the wall-clock is
|
||||
// untouched: the same 09:00 in another zone is a different instant, so
|
||||
// DTSTART has to move with it. So does a switch between the two passes of
|
||||
// the fall-back hour, which only the resolved instants tell apart (#387).
|
||||
// the fall-back hour, which each [FormTime] carries (#387).
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule ||
|
||||
updated.timezone != original.timezone ||
|
||||
updated.toWriteTimes(zone) != original.toWriteTimes(zone)
|
||||
updated.timezone != original.timezone
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
@@ -212,8 +213,8 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
calendarId = calendarId,
|
||||
title = title,
|
||||
isAllDay = true,
|
||||
start = start,
|
||||
end = start,
|
||||
start = FormTime(start),
|
||||
end = FormTime(start),
|
||||
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||
availability = Availability.Free,
|
||||
rrule = YEARLY_RRULE,
|
||||
|
||||
@@ -17,8 +17,8 @@ data class EventForm(
|
||||
val calendarId: Long?,
|
||||
val title: String = "",
|
||||
val isAllDay: Boolean = false,
|
||||
val start: LocalDateTime,
|
||||
val end: LocalDateTime,
|
||||
val start: FormTime,
|
||||
val end: FormTime,
|
||||
/**
|
||||
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||
* device — null is not "no zone", it is "whichever zone the device is in
|
||||
@@ -36,14 +36,6 @@ data class EventForm(
|
||||
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||
*/
|
||||
val timezone: String? = null,
|
||||
/**
|
||||
* 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,
|
||||
val location: String = "",
|
||||
val description: String = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
@@ -78,6 +70,19 @@ data class EventForm(
|
||||
val attendees: List<EventAttendee> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One end of a form's span: a wall-clock [dateTime] in the form's zone, and the
|
||||
* [offset] it was read at when the fall-back hour repeats that wall clock — both
|
||||
* of the hour's instants share one [LocalDateTime] (#387). Null everywhere else,
|
||||
* so a time changed and changed back compares equal again, and for a time picked
|
||||
* by hand, which can't say which pass it means and takes the earlier one. One
|
||||
* value, so a time never changes without its pass. See [toFormTime].
|
||||
*/
|
||||
data class FormTime(val dateTime: LocalDateTime, val offset: UtcOffset? = null) {
|
||||
val date: LocalDate get() = dateTime.date
|
||||
val time: LocalTime get() = dateTime.time
|
||||
}
|
||||
|
||||
/**
|
||||
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||
* write on), an optional display [name], and whether they're [optional] rather
|
||||
@@ -159,9 +164,9 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
val startDate = begin.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||
} else {
|
||||
begin.toLocalDateTime(formZone) to finish.toLocalDateTime(formZone)
|
||||
begin.toFormTime(formZone) to finish.toFormTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -170,8 +175,6 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = pinnedZone,
|
||||
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(),
|
||||
|
||||
@@ -37,23 +37,25 @@ fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
|
||||
/**
|
||||
* [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).
|
||||
* What a [FormTime.offset] holds (#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)
|
||||
/** This instant as a [FormTime] in [zone], keeping its pass of a repeated hour. */
|
||||
fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone), zone.repeatedHourOffsetAt(this))
|
||||
|
||||
/** The form's end as an instant in [zone], keeping its [EventForm.endOffset]. */
|
||||
fun EventForm.endInstant(zone: TimeZone): Instant = end.resolveIn(zone, endOffset)
|
||||
/** 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)
|
||||
|
||||
/** The form's timed span set to these instants, read in [zone] with their offsets. */
|
||||
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm = copy(
|
||||
start = start.toLocalDateTime(zone),
|
||||
end = end.toLocalDateTime(zone),
|
||||
startOffset = zone.repeatedHourOffsetAt(start),
|
||||
endOffset = zone.repeatedHourOffsetAt(end),
|
||||
)
|
||||
/** The form's start as an instant in [zone]. */
|
||||
fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone)
|
||||
|
||||
/** The form's end as an instant in [zone]. */
|
||||
fun EventForm.endInstant(zone: TimeZone): Instant = end.instantIn(zone)
|
||||
|
||||
/** The form's timed span set to these instants, read in [zone]. */
|
||||
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm =
|
||||
copy(start = start.toFormTime(zone), end = end.toFormTime(zone))
|
||||
|
||||
/**
|
||||
* The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour
|
||||
@@ -62,16 +64,16 @@ 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(startOffset = null, endOffset = null)
|
||||
return pinned.copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the earlier — unless it is the end the form already has.
|
||||
* pass of a repeated hour it means, so it takes the earlier — unless it is the
|
||||
* end the form already has.
|
||||
*/
|
||||
fun EventForm.withEnd(end: LocalDateTime): EventForm =
|
||||
if (end == this.end) this else copy(end = end, endOffset = null)
|
||||
if (end == this.end.dateTime) this else copy(end = FormTime(end))
|
||||
|
||||
/**
|
||||
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||
@@ -98,13 +100,13 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||
if (isAllDay) {
|
||||
return copy(
|
||||
start = newStart,
|
||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||
start = FormTime(newStart),
|
||||
end = FormTime(LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time)),
|
||||
)
|
||||
}
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = endInstant(zone) - startInstant(zone)
|
||||
val startAt = newStart.resolveIn(zone, startOffset)
|
||||
val startAt = FormTime(newStart, start.offset).instantIn(zone)
|
||||
return withTimes(startAt, startAt + span, zone)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,15 +34,7 @@ fun buildInsertEventForm(
|
||||
zone: TimeZone,
|
||||
now: Instant,
|
||||
): EventForm {
|
||||
val timed = if (isAllDay) {
|
||||
null
|
||||
} else {
|
||||
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
|
||||
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
|
||||
?: (startAt + 1.hours)
|
||||
startAt to endAt
|
||||
}
|
||||
val (start, end) = if (timed == null) {
|
||||
val (start, end) = if (isAllDay) {
|
||||
// All-day provider times are UTC midnights with an exclusive end; show
|
||||
// the last covered day and keep placeholder wall-clock times in case the
|
||||
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
||||
@@ -53,9 +45,12 @@ fun buildInsertEventForm(
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||
?: startDate
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||
} else {
|
||||
timed.first.toLocalDateTime(zone) to timed.second.toLocalDateTime(zone)
|
||||
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
|
||||
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
|
||||
?: (startAt + 1.hours)
|
||||
startAt.toFormTime(zone) to endAt.toFormTime(zone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
@@ -63,8 +58,6 @@ fun buildInsertEventForm(
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
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:"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -25,9 +26,9 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||
} else {
|
||||
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
||||
this.start.toFormTime(zone) to this.end.toFormTime(zone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
@@ -35,8 +36,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = 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(),
|
||||
|
||||
@@ -668,11 +668,11 @@ private fun EventEditContent(
|
||||
) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val formZone = remember(form.timezone) { form.resolvedZone(TimeZone.currentSystemDefault()) }
|
||||
val startAt = remember(form.start, form.startOffset, formZone) { form.startInstant(formZone) }
|
||||
val endAt = remember(form.end, form.endOffset, formZone) { form.endInstant(formZone) }
|
||||
val startAt = remember(form.start, formZone) { form.startInstant(formZone) }
|
||||
val endAt = remember(form.end, formZone) { form.endInstant(formZone) }
|
||||
ScheduleRow(
|
||||
label = stringResource(R.string.event_edit_starts),
|
||||
dateTime = form.start,
|
||||
dateTime = form.start.dateTime,
|
||||
instant = startAt,
|
||||
zone = formZone,
|
||||
isAllDay = form.isAllDay,
|
||||
@@ -683,7 +683,7 @@ private fun EventEditContent(
|
||||
)
|
||||
ScheduleRow(
|
||||
label = stringResource(R.string.event_edit_ends),
|
||||
dateTime = form.end,
|
||||
dateTime = form.end.dateTime,
|
||||
instant = endAt,
|
||||
zone = formZone,
|
||||
isAllDay = form.isAllDay,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -21,7 +22,9 @@ 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.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
|
||||
@@ -142,6 +145,10 @@ class EventEditViewModel @Inject constructor(
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The zone the device is in, read fresh each time; tests pin it. */
|
||||
@VisibleForTesting
|
||||
internal var deviceZone: () -> TimeZone = { TimeZone.currentSystemDefault() }
|
||||
|
||||
private val _form = MutableStateFlow<EventForm?>(null)
|
||||
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
|
||||
// Problems stay hidden until the first save attempt, so a half-filled
|
||||
@@ -297,7 +304,7 @@ class EventEditViewModel @Inject constructor(
|
||||
form = resolved,
|
||||
calendars = pickerCalendars,
|
||||
problems = if (local.showProblems) {
|
||||
resolved.problems(TimeZone.currentSystemDefault())
|
||||
resolved.problems(deviceZone())
|
||||
} else {
|
||||
emptySet()
|
||||
},
|
||||
@@ -344,7 +351,7 @@ class EventEditViewModel @Inject constructor(
|
||||
*/
|
||||
fun openNew(date: LocalDate, startMinutes: Int? = null, durationMinutes: Int? = null) {
|
||||
if (_form.value != null) return
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val zone = deviceZone()
|
||||
val now = Clock.System.now()
|
||||
val startAt = when {
|
||||
startMinutes != null ->
|
||||
@@ -354,8 +361,7 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
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)
|
||||
_form.value = EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone))
|
||||
if (chosen != null) _durationTouched.value = true
|
||||
applyDefaultReminder()
|
||||
applyDefaultDuration()
|
||||
@@ -467,7 +473,7 @@ class EventEditViewModel @Inject constructor(
|
||||
if (_form.value?.isAllDay != false) return@launch
|
||||
val duration = resolveDefaultEventDuration(global, overrides, targetId)
|
||||
update { form ->
|
||||
val zone = form.resolvedZone(TimeZone.currentSystemDefault())
|
||||
val zone = form.resolvedZone(deviceZone())
|
||||
val start = form.startInstant(zone)
|
||||
form.withTimes(start, start + duration.minutes, zone)
|
||||
}
|
||||
@@ -530,7 +536,7 @@ class EventEditViewModel @Inject constructor(
|
||||
_loadFailed.value = true
|
||||
return@launch
|
||||
}
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val zone = deviceZone()
|
||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.start.toEpochMilliseconds()
|
||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
@@ -583,8 +589,11 @@ 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, startOffset = null, endOffset = null)
|
||||
else it.copy(isAllDay = false)
|
||||
if (value) {
|
||||
it.copy(isAllDay = true, timezone = null, start = FormTime(it.start.dateTime), end = FormTime(it.end.dateTime))
|
||||
} 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).
|
||||
@@ -600,7 +609,7 @@ class EventEditViewModel @Inject constructor(
|
||||
* time — only real picks are remembered, not the device fallback.
|
||||
*/
|
||||
fun setTimezone(zoneId: String?) {
|
||||
update { it.withTimezone(zoneId, TimeZone.currentSystemDefault()) }
|
||||
update { it.withTimezone(zoneId, deviceZone()) }
|
||||
if (zoneId != null) {
|
||||
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||
}
|
||||
@@ -696,7 +705,7 @@ class EventEditViewModel @Inject constructor(
|
||||
val current = state.value ?: return
|
||||
if (current.saveState == SaveUiState.Saving) return
|
||||
val form = current.form
|
||||
if (form.problems(TimeZone.currentSystemDefault()).isNotEmpty()) {
|
||||
if (form.problems(deviceZone()).isNotEmpty()) {
|
||||
_showProblems.value = true
|
||||
return
|
||||
}
|
||||
@@ -825,12 +834,12 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||
val zone = form.resolvedZone(TimeZone.currentSystemDefault())
|
||||
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)
|
||||
val newStart = picked.resolveIn(zone, form.startOffset.takeIf { picked == form.start })
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+19
-18
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -510,8 +511,8 @@ class CalendarRepositoryImplTest {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
|
||||
)
|
||||
|
||||
val id = repo.createEvent(form)
|
||||
@@ -531,8 +532,8 @@ class CalendarRepositoryImplTest {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
isAllDay = true,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
|
||||
)
|
||||
|
||||
repo.createEvent(form)
|
||||
@@ -548,8 +549,8 @@ class CalendarRepositoryImplTest {
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -567,8 +568,8 @@ class CalendarRepositoryImplTest {
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
|
||||
)
|
||||
val updated = original.copy(title = "Daily")
|
||||
|
||||
@@ -584,8 +585,8 @@ class CalendarRepositoryImplTest {
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
|
||||
)
|
||||
val updated = original.copy(calendarId = 3L)
|
||||
|
||||
@@ -605,8 +606,8 @@ class CalendarRepositoryImplTest {
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -625,8 +626,8 @@ class CalendarRepositoryImplTest {
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -678,8 +679,8 @@ class CalendarRepositoryImplTest {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Moved",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||
)
|
||||
|
||||
val id = repo.updateOccurrence(
|
||||
@@ -701,8 +702,8 @@ class CalendarRepositoryImplTest {
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Weekly",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
|
||||
rrule = "FREQ=WEEKLY",
|
||||
)
|
||||
val updated = original.copy(title = "Weekly, renamed")
|
||||
|
||||
+42
-38
@@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
@@ -26,8 +27,8 @@ class EventWriteMapperTest {
|
||||
): EventForm = EventForm(
|
||||
calendarId = 1L,
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
start = FormTime(start),
|
||||
end = FormTime(end),
|
||||
timezone = timezone,
|
||||
)
|
||||
|
||||
@@ -156,8 +157,8 @@ class EventWriteMapperTest {
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
|
||||
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
|
||||
)
|
||||
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
|
||||
.isEqualTo("America/New_York")
|
||||
@@ -203,8 +204,8 @@ class EventWriteMapperTest {
|
||||
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
|
||||
val original = form()
|
||||
val updated = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))),
|
||||
)
|
||||
val values = update(original, updated)
|
||||
// 2026-06-11 11:00 CEST == 09:00Z.
|
||||
@@ -223,8 +224,8 @@ class EventWriteMapperTest {
|
||||
val original = form().copy(rrule = "FREQ=WEEKLY")
|
||||
val updated = original.copy(
|
||||
// Pushed one hour later than the displayed occurrence.
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
|
||||
)
|
||||
val values = update(original, updated)
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
|
||||
@@ -265,8 +266,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0))),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||
@@ -281,8 +282,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
|
||||
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30))),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||
@@ -299,8 +300,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0))),
|
||||
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
|
||||
)
|
||||
|
||||
@@ -321,8 +322,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30))),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
|
||||
@@ -333,8 +334,8 @@ class EventWriteMapperTest {
|
||||
fun `shifting a one-off event and back again restores the original values`() {
|
||||
val original = form()
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0))),
|
||||
)
|
||||
|
||||
val forward = update(original, moved)
|
||||
@@ -359,8 +360,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
|
||||
)
|
||||
|
||||
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
|
||||
@@ -680,8 +681,8 @@ class EventWriteMapperTest {
|
||||
|
||||
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
|
||||
private fun EventForm.atHour(hour: Int): EventForm = copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0))),
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -730,8 +731,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||
@@ -750,8 +751,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||
)
|
||||
|
||||
val values = update(original, moved, series, "20260722T000000Z")
|
||||
@@ -770,8 +771,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||
)
|
||||
|
||||
val values = update(original, moved, series, "20260722T000000")
|
||||
@@ -789,8 +790,8 @@ class EventWriteMapperTest {
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
|
||||
)
|
||||
|
||||
val values = update(original, moved, series, "20260722T090000Z")
|
||||
@@ -817,8 +818,8 @@ class EventWriteMapperTest {
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val timed = original.copy(
|
||||
isAllDay = false,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
|
||||
)
|
||||
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
|
||||
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||
@@ -900,8 +901,8 @@ class EventWriteMapperTest {
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = julySeries()
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
|
||||
)
|
||||
val exdate = "20260722T070000Z"
|
||||
|
||||
@@ -1394,7 +1395,7 @@ class EventWriteMapperTest {
|
||||
fun `a time in the repeated fall-back hour is written at its own offset`() {
|
||||
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 0))
|
||||
val times = form(start = repeated, end = repeated)
|
||||
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 1))
|
||||
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
|
||||
.toWriteTimes(berlin)
|
||||
assertThat(times.dtStartMillis).isEqualTo(Instant.parse("2026-10-25T00:00:00Z").toEpochMilliseconds())
|
||||
assertThat(times.dtEndMillis).isEqualTo(Instant.parse("2026-10-25T01:00:00Z").toEpochMilliseconds())
|
||||
@@ -1405,8 +1406,11 @@ class EventWriteMapperTest {
|
||||
// 02:30 CEST to 02:30 CET: the same wall clock, an hour later.
|
||||
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
|
||||
val original = form(start = repeated, end = repeated)
|
||||
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 2))
|
||||
val updated = original.copy(startOffset = UtcOffset(hours = 1), endOffset = UtcOffset(hours = 1))
|
||||
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 2)))
|
||||
val updated = original.copy(
|
||||
start = original.start.copy(offset = UtcOffset(hours = 1)),
|
||||
end = original.end.copy(offset = UtcOffset(hours = 1)),
|
||||
)
|
||||
val values = update(original, updated)
|
||||
assertThat(values[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
|
||||
@@ -1418,7 +1422,7 @@ class EventWriteMapperTest {
|
||||
fun `an untouched form in the repeated fall-back hour writes no times`() {
|
||||
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
|
||||
val original = form(start = repeated, end = repeated)
|
||||
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 1))
|
||||
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
|
||||
assertThat(update(original, original.copy(title = "Renamed")))
|
||||
.doesNotContainKey(CalendarContract.Events.DTSTART)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class EventFormTest {
|
||||
isAllDay: Boolean = false,
|
||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
|
||||
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = FormTime(start), end = FormTime(end))
|
||||
|
||||
@Test
|
||||
fun `valid timed form has no problems`() {
|
||||
@@ -152,8 +152,8 @@ class EventFormTest {
|
||||
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
|
||||
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
assertThat(prefilled.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
|
||||
assertThat(prefilled.isAllDay).isFalse()
|
||||
assertThat(prefilled.calendarId).isEqualTo(7L)
|
||||
assertThat(prefilled.title).isEqualTo("Stand-up")
|
||||
@@ -184,7 +184,7 @@ class EventFormTest {
|
||||
assertThat(prefilled.timezone).isEqualTo("America/New_York")
|
||||
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
|
||||
// event's own wall-clock, which is what a later save re-anchors to.
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
||||
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
||||
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@@ -208,15 +208,15 @@ class EventFormTest {
|
||||
)
|
||||
// A malformed sync row must not be honoured, nor fail the open.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isPinnedAwayFrom is false when there is nothing to disambiguate`() {
|
||||
val base = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
|
||||
)
|
||||
// Unpinned: the form's times already are the local times.
|
||||
assertThat(base.isPinnedAwayFrom(berlin)).isFalse()
|
||||
@@ -392,7 +392,7 @@ class EventFormTest {
|
||||
val start = Instant.parse("2026-10-25T00:00:00Z")
|
||||
val end = Instant.parse("2026-10-25T01:00:00Z")
|
||||
val prefilled = detail().toEditForm(start.toEpochMilliseconds(), end.toEpochMilliseconds(), berlin)
|
||||
assertThat(prefilled.start).isEqualTo(prefilled.end)
|
||||
assertThat(prefilled.start.dateTime).isEqualTo(prefilled.end.dateTime)
|
||||
assertThat(prefilled.startInstant(berlin)).isEqualTo(start)
|
||||
assertThat(prefilled.endInstant(berlin)).isEqualTo(end)
|
||||
}
|
||||
@@ -405,10 +405,10 @@ class EventFormTest {
|
||||
Instant.parse("2026-10-25T01:15:00Z"),
|
||||
berlin,
|
||||
)
|
||||
assertThat(form.end).isLessThan(form.start)
|
||||
assertThat(form.end.dateTime).isLessThan(form.start.dateTime)
|
||||
assertThat(form.problems(berlin)).isEmpty()
|
||||
// Read as the same pass at both ends, it really does end first.
|
||||
assertThat(form.copy(startOffset = null, endOffset = null).problems(berlin))
|
||||
assertThat(form.copy(start = FormTime(form.start.dateTime), end = FormTime(form.end.dateTime)).problems(berlin))
|
||||
.containsExactly(EventFormProblem.EndBeforeStart)
|
||||
}
|
||||
|
||||
@@ -420,9 +420,9 @@ class EventFormTest {
|
||||
berlin,
|
||||
)
|
||||
// Picking the end it already has keeps its pass.
|
||||
assertThat(form.withEnd(form.end)).isEqualTo(form)
|
||||
assertThat(form.withEnd(form.end.dateTime)).isEqualTo(form)
|
||||
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50)))
|
||||
assertThat(picked.endOffset).isNull()
|
||||
assertThat(picked.end.offset).isNull()
|
||||
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
|
||||
}
|
||||
|
||||
@@ -430,8 +430,8 @@ class EventFormTest {
|
||||
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)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
val zone = form.resolvedZone(berlin)
|
||||
@@ -446,8 +446,8 @@ class EventFormTest {
|
||||
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)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0))),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
// 20:00 New York is 02:00 the NEXT day in Berlin.
|
||||
@@ -460,8 +460,8 @@ class EventFormTest {
|
||||
// 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)),
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0))),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin))
|
||||
@@ -475,15 +475,15 @@ class EventFormTest {
|
||||
Instant.parse("2026-06-11T09:00:00Z"),
|
||||
berlin,
|
||||
)
|
||||
assertThat(summer.startOffset).isNull()
|
||||
assertThat(summer.endOffset).isNull()
|
||||
assertThat(summer.start.offset).isNull()
|
||||
assertThat(summer.end.offset).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))
|
||||
assertThat(repeated.start.offset).isEqualTo(UtcOffset(hours = 2))
|
||||
assertThat(repeated.end.offset).isEqualTo(UtcOffset(hours = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -495,7 +495,7 @@ class EventFormTest {
|
||||
)
|
||||
val back = original
|
||||
.withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
|
||||
.withEnd(original.end)
|
||||
.withEnd(original.end.dateTime)
|
||||
assertThat(back).isEqualTo(original)
|
||||
}
|
||||
|
||||
@@ -508,8 +508,8 @@ class EventFormTest {
|
||||
)
|
||||
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()
|
||||
assertThat(moved.start.offset).isNull()
|
||||
assertThat(moved.end.offset).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"))
|
||||
|
||||
@@ -24,8 +24,8 @@ class EventShiftTest {
|
||||
calendarId = 1L,
|
||||
title = "Standup",
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
start = FormTime(start),
|
||||
end = FormTime(end),
|
||||
timezone = timezone,
|
||||
)
|
||||
|
||||
@@ -36,8 +36,8 @@ class EventShiftTest {
|
||||
|
||||
val moved = original.shiftedTo(target, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
|
||||
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
|
||||
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -54,8 +54,8 @@ class EventShiftTest {
|
||||
val moved = original.shiftedTo(target, berlin)
|
||||
|
||||
assertThat(moved.timezone).isEqualTo("America/New_York")
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
|
||||
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
||||
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -71,10 +71,10 @@ class EventShiftTest {
|
||||
|
||||
// Two real hours from 01:00 CET lands at 04:00 CEST: the hour in
|
||||
// between does not exist.
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
|
||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
||||
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
|
||||
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
|
||||
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
|
||||
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
|
||||
.isEqualTo(original.end.dateTime.toInstant(berlin) - original.start.dateTime.toInstant(berlin))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,14 +85,14 @@ class EventShiftTest {
|
||||
|
||||
val moved = original.shiftedTo(target, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
|
||||
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-distance drop leaves the form untouched`() {
|
||||
// As read from the provider, offsets and all.
|
||||
val start = form().start.toInstant(berlin)
|
||||
val original = form().withTimes(start, form().end.toInstant(berlin), berlin)
|
||||
val start = form().start.dateTime.toInstant(berlin)
|
||||
val original = form().withTimes(start, form().end.dateTime.toInstant(berlin), berlin)
|
||||
assertThat(original.shiftedTo(start, berlin)).isEqualTo(original)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ class EventShiftTest {
|
||||
|
||||
val moved = original.shiftedTo(target, newYork)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
|
||||
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -129,8 +129,8 @@ class EventShiftTest {
|
||||
|
||||
val moved = multiDay.shiftedByDays(-2, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
|
||||
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
|
||||
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -144,11 +144,11 @@ class EventShiftTest {
|
||||
|
||||
val moved = overnight.shiftedByDays(7, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
|
||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
||||
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
|
||||
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
|
||||
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
|
||||
.isEqualTo(overnight.end.dateTime.toInstant(berlin) - overnight.start.dateTime.toInstant(berlin))
|
||||
// The wall-clock end therefore lands an hour later than a naive +7 days.
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
|
||||
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -162,10 +162,10 @@ class EventShiftTest {
|
||||
val original = form()
|
||||
val end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)).toInstant(berlin)
|
||||
|
||||
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
|
||||
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
|
||||
|
||||
assertThat(resized.start).isEqualTo(original.start)
|
||||
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
|
||||
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -173,9 +173,9 @@ class EventShiftTest {
|
||||
val original = form()
|
||||
val end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)).toInstant(berlin)
|
||||
|
||||
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
|
||||
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
|
||||
|
||||
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
|
||||
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -188,16 +188,16 @@ class EventShiftTest {
|
||||
// "15:30 Berlin" is 09:30 in New York.
|
||||
val start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
|
||||
|
||||
val resized = original.resizedTo(start, original.end.toInstant(newYork), berlin)
|
||||
val resized = original.resizedTo(start, original.end.dateTime.toInstant(newYork), berlin)
|
||||
|
||||
assertThat(resized.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
||||
assertThat(resized.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
||||
assertThat(resized.end).isEqualTo(original.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event ignores a resize`() {
|
||||
val allDay = form(isAllDay = true)
|
||||
val start = allDay.start.toInstant(berlin)
|
||||
val start = allDay.start.dateTime.toInstant(berlin)
|
||||
|
||||
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ class EventShiftTest {
|
||||
fun `a move onto the second repeated hour stays there`() {
|
||||
val target = Instant.parse("2026-10-25T01:30:00Z")
|
||||
val moved = form().withTimes(repeatStart, repeatEnd, berlin).shiftedTo(target, berlin)
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)))
|
||||
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)))
|
||||
assertThat(moved.startInstant(berlin)).isEqualTo(target)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ class InsertEventFormTest {
|
||||
)
|
||||
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
|
||||
assertThat(form.isAllDay).isFalse()
|
||||
assertThat(form.start).isEqualTo(start)
|
||||
assertThat(form.end).isEqualTo(end)
|
||||
assertThat(form.start.dateTime).isEqualTo(start)
|
||||
assertThat(form.end.dateTime).isEqualTo(end)
|
||||
assertThat(form.title).isEqualTo("Standup")
|
||||
assertThat(form.description).isEqualTo("Daily")
|
||||
assertThat(form.location).isEqualTo("Room 1")
|
||||
@@ -64,8 +64,8 @@ class InsertEventFormTest {
|
||||
@Test
|
||||
fun `missing times default to the next full hour and a one-hour duration`() {
|
||||
val form = build(title = "Quick note")
|
||||
assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
|
||||
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
|
||||
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
|
||||
assertThat(form.title).isEqualTo("Quick note")
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ class InsertEventFormTest {
|
||||
fun `timed event with only a start gets a one-hour end`() {
|
||||
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
||||
val form = build(beginMillis = millis(start))
|
||||
assertThat(form.start).isEqualTo(start)
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||
assertThat(form.start.dateTime).isEqualTo(start)
|
||||
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -82,7 +82,7 @@ class InsertEventFormTest {
|
||||
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
||||
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
|
||||
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -29,8 +29,8 @@ class ParsedIcsFormTest {
|
||||
assertThat(form.calendarId).isNull()
|
||||
assertThat(form.title).isEqualTo("Call")
|
||||
// 13:00 UTC == 15:00 Berlin (summer).
|
||||
assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
|
||||
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
|
||||
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
|
||||
assertThat(form.reminders).containsExactly(5, 10).inOrder()
|
||||
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.startInstant
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -26,6 +30,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -221,12 +226,12 @@ class EventEditViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
// Calendar 1 has no length of its own, so it inherits the global 30 min.
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
|
||||
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@@ -246,7 +251,7 @@ class EventEditViewModelTest {
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
|
||||
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@@ -267,7 +272,7 @@ class EventEditViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
// The file owns the length; a calendar switch must not restretch it.
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
|
||||
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@@ -288,15 +293,15 @@ class EventEditViewModelTest {
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun importedForm(end: LocalDateTime) = EventForm(
|
||||
calendarId = null,
|
||||
title = "Imported",
|
||||
start = LocalDateTime(2030, 1, 15, 9, 0),
|
||||
end = end,
|
||||
start = FormTime(LocalDateTime(2030, 1, 15, 9, 0)),
|
||||
end = FormTime(end),
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -316,7 +321,7 @@ class EventEditViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
// Dates carry no length: the times stay where the form put them.
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
|
||||
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@@ -407,4 +412,44 @@ class EventEditViewModelTest {
|
||||
|
||||
assertThat(capLength(new, "")).isEqualTo("a".repeat(254))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drafted block opens at its real length across the fall-back hour`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||
val vm = viewModel(tempDir, fake).apply { deviceZone = { berlin } }
|
||||
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)
|
||||
advanceUntilIdle()
|
||||
|
||||
val form = vm.state.value!!.form!!
|
||||
assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
|
||||
assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
|
||||
assertThat(form.problems(berlin)).isEmpty()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drafted block in the spring-forward gap keeps its length`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||
val vm = viewModel(tempDir, fake).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)
|
||||
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()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user