Read the device zone from one source, and settle more DST edges (#387)

A ZoneSource now gives the device's zone to the timeline, the event form and
reschedules, so all three agree on one zone. The timeline lays its pages out
again when the device moves zones, and the form shows its local times in the
same zone it reads them in.

- A day move onto the spring-forward gap keeps its wall clock, so a weekly
  series moved onto that day is still offered as a day move.
- A resize handle let go in the repeated hour takes the pass the grid draws
  there, so the block settles under the finger.
- Switching to all-day and back keeps a time on its pass of the repeated
  hour.
- A series anchored in the repeated hour keeps its pass through a time edit,
  and an edit of the anchor occurrence carries the pass it picked.
- An end picked in the spring-forward gap moves on to the real time, as a
  start does.
- The editor opens a second draft or slot on the same day too.
- The repeated-hour rule leans on java.time's own overlap handling.
This commit is contained in:
2026-10-14 15:30:26 +02:00
parent f410e9db8b
commit c56bf7b607
18 changed files with 267 additions and 60 deletions
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneOffset
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -213,15 +214,23 @@ internal fun buildEventUpdateValues(
// is a different offset from the series anchor's whenever a DST boundary
// sits between them, or whenever the zone itself changed. Working in wall
// clock keeps "09:00" meaning 09:00 at both ends.
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
.atZone(original.writeZone(zone)).toLocalDateTime()
val seriesZoned = Instant.ofEpochMilli(seriesDtStartMillis).atZone(original.writeZone(zone))
val seriesLocal = seriesZoned.toLocalDateTime()
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
val shifted = seriesLocal.plus(wallClockShift)
// An all-day series anchor must sit on a UTC midnight. A pure day move
// already lands there (both ends are midnights), but *switching* a
// recurring event to all-day shifts by a time-of-day too, so snap.
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
// A wall clock the fall-back hour repeats keeps the anchor's own pass, or,
// when the anchor is the edited occurrence itself, the pass the form now
// holds: a bare wall clock would always take the earlier one (#387).
val pass = if (seriesLocal == original.anchorLocal()) {
updated.start.offset?.toJavaZoneOffset()
} else {
seriesZoned.offset
}
val newSeriesStart = ZonedDateTime.ofLocal(newSeriesLocal, updated.writeZone(zone), pass)
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule)
@@ -21,7 +21,9 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
@@ -69,6 +71,12 @@ abstract class DataBindModule {
abstract fun bindTodaySource(
impl: AndroidTodaySource,
): TodaySource
@Binds
@Singleton
abstract fun bindZoneSource(
impl: AndroidZoneSource,
): ZoneSource
}
@Module
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.datetime.TimeZone
import javax.inject.Inject
/**
* The zone the device is in. Every screen that turns wall clock into instants
* reads it from here, so a timeline, the form it opens and the move it saves
* agree on one zone (#387).
*/
interface ZoneSource {
/** The device's zone, re-emitted whenever it changes. */
val zone: Flow<TimeZone>
/** The device's zone right now. */
fun current(): TimeZone
}
/** Follows the system's zone broadcast. */
class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ZoneSource {
override val zone: Flow<TimeZone> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
.map { current() }
.distinctUntilChanged()
override fun current(): TimeZone = TimeZone.currentSystemDefault()
}
@@ -74,9 +74,9 @@ data class EventForm(
* 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].
* so a time changed and changed back compares equal again. A time picked by hand
* gets its pass from [repicked]; one without an offset reads as the earlier
* pass. 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
@@ -9,10 +9,13 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toJavaZoneOffset
import kotlinx.datetime.toKotlinUtcOffset
import kotlinx.datetime.toLocalDateTime
import java.time.ZonedDateTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant
/**
* The zone this form's wall-clock times mean, matching what the data layer
@@ -25,15 +28,14 @@ fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
* it and that is one of its two offsets — otherwise the earlier one (#387).
*/
fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant {
val kept = preferred?.let { toInstant(it) }
return kept?.takeIf { it.toLocalDateTime(zone) == this } ?: toInstant(zone)
}
fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant =
ZonedDateTime.ofLocal(toJavaLocalDateTime(), zone.toJavaZoneId(), preferred?.toJavaZoneOffset())
.toInstant().toKotlinInstant()
/** Whether this instant's wall clock in [zone] is one the fall-back hour repeats. */
fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
val zid = zone.toJavaZoneId()
return zid.rules.getValidOffsets(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid)).size > 1
return zid.rules.getTransition(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid))?.isOverlap == true
}
/**
@@ -51,14 +53,16 @@ fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offse
/**
* This edge of an event moved to [picked] by hand — a picker or a resize handle —
* as a [FormTime] in [zone]. A wall clock the fall-back hour repeats has two
* instants; which one it means (#387):
* as a [FormTime] in [zone]. A time in the spring-forward gap moves on to the
* real one, as the provider would read it. A wall clock the fall-back hour
* repeats has two instants; which one it means (#387):
* - [original]'s, when [picked] is the wall clock the event was opened with, so
* a time changed and changed back is the time it was;
* - else the one that moves the edge the way the hand did: earlier than this
* edge's instant when [picked] is before [shownAt] — where the edge was on
* screen — later when after;
* - kept within [notBefore]..[notAfter] (the other edge) when either fits.
* - kept within [notBefore]..[notAfter] (the other edge), and to the ones
* [drawable] accepts — where the screen can show it — when any fit.
*/
fun FormTime.repicked(
picked: LocalDateTime,
@@ -67,14 +71,16 @@ fun FormTime.repicked(
original: FormTime? = null,
notBefore: Instant? = null,
notAfter: Instant? = null,
drawable: (Instant) -> Boolean = { true },
): FormTime {
if (picked == dateTime && picked == shownAt) return this
val zid = zone.toJavaZoneId()
val offsets = zid.rules.getValidOffsets(picked.toJavaLocalDateTime())
if (offsets.size < 2) return FormTime(picked)
val offsets = zone.toJavaZoneId().rules.getValidOffsets(picked.toJavaLocalDateTime())
if (offsets.isEmpty()) return FormTime(picked).instantIn(zone).toFormTime(zone)
if (offsets.size == 1) return FormTime(picked)
val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted()
val fitting = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
val bounded = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
.ifEmpty { passes }
val fitting = bounded.filter(drawable).ifEmpty { bounded }
if (original?.dateTime == picked && original.offset != null && original.instantIn(zone) in fitting) return original
val from = instantIn(zone)
val chosen = when {
@@ -158,7 +164,10 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val zone = resolvedZone(deviceZone)
val span = endInstant(zone) - startInstant(zone)
val startAt = FormTime(newStart, start.offset).instantIn(zone)
return withTimes(startAt, startAt + span, zone)
// The start keeps the wall clock it was moved to, even one the spring-forward
// gap skips: a day move stays a day move, which a recurring series relies on.
val movedStart = startAt.toFormTime(zone).takeIf { it.dateTime == newStart } ?: FormTime(newStart)
return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
}
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
@@ -130,6 +131,7 @@ sealed interface MoveOutcome {
class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
@@ -319,7 +321,7 @@ class RescheduleViewModel @Inject constructor(
return null
}
val zone = TimeZone.currentSystemDefault()
val zone = zoneSource.current()
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
@@ -497,7 +499,7 @@ class RescheduleViewModel @Inject constructor(
private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated
return form.endInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds()
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
}
/**
@@ -509,7 +511,7 @@ class RescheduleViewModel @Inject constructor(
return if (form.isAllDay) {
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
} else {
form.startInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds()
form.startInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
}
}
@@ -328,6 +328,8 @@ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val
* 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's pass of a
* repeated hour follows the hand from where the edge was drawn: see [repicked].
* An end takes a pass the grid draws where it was let go, so the block settles
* under the finger.
*/
internal fun resizedDrop(
event: EventInstance,
@@ -351,11 +353,15 @@ internal fun resizedDrop(
event.start
},
end = if (edge == ResizeEdge.Bottom) {
val picked = wallClockTime(date, startMin + spanMin)
val pickedMin = (picked.date.toEpochDays() - drawn.day.toEpochDays()).toInt() * MINUTES_PER_DAY +
picked.time.toSecondOfDay() / 60
event.end.toFormTime(zone).repicked(
wallClockTime(date, startMin + spanMin),
picked,
zone,
shownAt = wallClockTime(drawn.day, drawn.endMin),
notBefore = event.start,
drawable = { wallClockEndMinutes(drawn.startMin, event.start, it, drawn.day, zone) == pickedMin },
).instantIn(zone)
} else {
event.end
@@ -212,7 +212,9 @@ fun EventEditScreen(
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
// Every input that picks the form is a key: a second draft or slot on the
// same day must open its own form, even into a composition kept alive.
LaunchedEffect(initialDateIso, editKey, initialForm, initialDraft, initialStartMinutes) {
when {
// A prefilled open: a single-event .ics for review, or an external
// ACTION_INSERT intent. The source drives how reminders are seeded.
@@ -229,7 +231,7 @@ fun EventEditScreen(
)
else -> {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
?: Clock.System.now().toLocalDateTime(viewModel.zone.value).date
viewModel.openNew(date, initialStartMinutes)
}
}
@@ -511,6 +513,7 @@ private fun EventEditContent(
) {
val form = state.form
val locale = currentLocale()
val deviceZone by viewModel.zone.collectAsStateWithLifecycle()
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val dark = isSystemInDarkTheme()
// The title, date and recurrence are managed by the special-dates sync, so
@@ -672,7 +675,7 @@ private fun EventEditContent(
},
) {
Spacer(Modifier.height(4.dp))
val formZone = remember(form.timezone) { form.resolvedZone(TimeZone.currentSystemDefault()) }
val formZone = remember(form.timezone, deviceZone) { form.resolvedZone(deviceZone) }
val startAt = remember(form.start, formZone) { form.startInstant(formZone) }
val endAt = remember(form.end, formZone) { form.endInstant(formZone) }
ScheduleRow(
@@ -714,15 +717,15 @@ private fun EventEditContent(
// 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())
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
form.isPinnedAwayFrom(deviceZone)
}
if (pinnedAway) {
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(
R.string.event_edit_timezone_local_time,
formatTimeRange(startAt, endAt, TimeZone.currentSystemDefault(), locale),
formatTimeRange(startAt, endAt, deviceZone, locale),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -1,7 +1,6 @@
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
@@ -10,6 +9,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
@@ -26,7 +26,6 @@ import de.jeanlucmakiola.calendula.domain.toFormTime
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.withEnd
import de.jeanlucmakiola.calendula.domain.withoutKeptPasses
import de.jeanlucmakiola.calendula.domain.withStart
import de.jeanlucmakiola.calendula.domain.withTimes
import de.jeanlucmakiola.calendula.domain.withTimezone
@@ -144,11 +143,12 @@ class EventEditViewModel @Inject constructor(
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
/** The zone the device is in, read fresh each time; tests pin it. */
@VisibleForTesting
internal var deviceZone: () -> TimeZone = { TimeZone.currentSystemDefault() }
/** The zone the form's unpinned times are read in, for the screen to show them in too. */
val zone: StateFlow<TimeZone> = zoneSource.zone
.stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current())
private val _form = MutableStateFlow<EventForm?>(null)
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
@@ -305,7 +305,7 @@ class EventEditViewModel @Inject constructor(
form = resolved,
calendars = pickerCalendars,
problems = if (local.showProblems) {
resolved.problems(deviceZone())
resolved.problems(zoneSource.current())
} else {
emptySet()
},
@@ -348,7 +348,7 @@ class EventEditViewModel @Inject constructor(
*/
fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return
val zone = deviceZone()
val zone = zoneSource.current()
val now = Clock.System.now()
val startAt = when {
startMinutes != null ->
@@ -371,7 +371,7 @@ class EventEditViewModel @Inject constructor(
*/
fun openDraft(start: Instant, end: Instant) {
if (_form.value != null) return
val zone = deviceZone()
val zone = zoneSource.current()
_form.value = EventForm(calendarId = null, start = start.toFormTime(zone), end = end.toFormTime(zone))
_durationTouched.value = true
applyDefaultReminder()
@@ -484,7 +484,7 @@ class EventEditViewModel @Inject constructor(
if (_form.value?.isAllDay != false) return@launch
val duration = resolveDefaultEventDuration(global, overrides, targetId)
update { form ->
val zone = form.resolvedZone(deviceZone())
val zone = form.resolvedZone(zoneSource.current())
val start = form.startInstant(zone)
form.withTimes(start, start + duration.minutes, zone)
}
@@ -547,7 +547,7 @@ class EventEditViewModel @Inject constructor(
_loadFailed.value = true
return@launch
}
val zone = deviceZone()
val zone = zoneSource.current()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
@@ -598,9 +598,10 @@ 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.
// An all-day form's times are bare dates, so a kept pass goes with it.
// A kept pass of a repeated hour stays, so switching back out of all-day
// lands where the event was — unless dropping the pin changed the zone.
update {
if (value) it.copy(isAllDay = true, timezone = null).withoutKeptPasses() else it.copy(isAllDay = false)
if (value) it.withTimezone(null, zoneSource.current()).copy(isAllDay = true) 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).
@@ -616,7 +617,7 @@ class EventEditViewModel @Inject constructor(
* time — only real picks are remembered, not the device fallback.
*/
fun setTimezone(zoneId: String?) {
update { it.withTimezone(zoneId, deviceZone()) }
update { it.withTimezone(zoneId, zoneSource.current()) }
if (zoneId != null) {
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
}
@@ -712,7 +713,7 @@ class EventEditViewModel @Inject constructor(
val current = state.value ?: return
if (current.saveState == SaveUiState.Saving) return
val form = current.form
if (form.problems(deviceZone()).isNotEmpty()) {
if (form.problems(zoneSource.current()).isNotEmpty()) {
_showProblems.value = true
return
}
@@ -841,11 +842,11 @@ class EventEditViewModel @Inject constructor(
}
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
form.withStart(transform(form.start.dateTime), form.resolvedZone(deviceZone()), opened(form)?.start)
form.withStart(transform(form.start.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.start)
}
private fun moveEnd(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
form.withEnd(transform(form.end.dateTime), form.resolvedZone(deviceZone()), opened(form)?.end)
form.withEnd(transform(form.end.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.end)
}
/**
@@ -457,7 +457,7 @@ private fun PagedTimelineScreen(
// Off the ticking clock rather than the loaded page, so it turns over at
// midnight; derived, so the per-minute tick doesn't recompose the screen.
// The timeline's own zone, so "today" agrees with the blocks and drops (#387).
val zone = viewModel.zone
val zone by viewModel.zone.collectAsStateWithLifecycle()
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
@@ -500,7 +500,7 @@ private fun PagedTimelineScreen(
ScreenBody(modifier = modifier) { innerPadding ->
CompositionLocalProvider(
LocalDimCutoff provides dimCutoff,
LocalTimelineZone provides viewModel.zone,
LocalTimelineZone provides zone,
) {
WeekContent(
state = state,
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -81,13 +82,18 @@ abstract class PagedTimelineViewModel(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> PageGrid>,
initialRule: (LocalDate) -> PageGrid,
) : ViewModel() {
/** The zone every page is laid out in; drags and drops resolve in it too (#387). */
val zone: TimeZone = TimeZone.currentSystemDefault()
/**
* The zone every page is laid out in; drags and drops resolve in it too, and
* pages lay out again when the device moves zones (#387).
*/
val zone: StateFlow<TimeZone> = zoneSource.zone
.stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current())
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
@@ -166,12 +172,14 @@ abstract class PagedTimelineViewModel(
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
},
) { span ->
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today)
zone.flatMapLatest { zone ->
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today, zone)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
@@ -206,6 +214,7 @@ abstract class PagedTimelineViewModel(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
today: LocalDate,
zone: TimeZone,
): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
@@ -227,11 +236,13 @@ class WeekViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = settingsPrefs.weekStart.map { pref ->
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
@@ -251,11 +262,13 @@ class MultiDayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = settingsPrefs.multiDayCount.map { count ->
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
@@ -269,11 +282,13 @@ class DayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = flowOf { _: LocalDate -> DayGrid },
initialRule = { DayGrid },
@@ -1426,4 +1426,31 @@ class EventWriteMapperTest {
assertThat(update(original, original.copy(title = "Renamed")))
.doesNotContainKey(CalendarContract.Events.DTSTART)
}
@Test
fun `a series anchored on the second repeated hour keeps it through a time edit`() {
// The series starts 02:30 CET on the fall-back day; an occurrence a week
// later gets ten more minutes.
val anchor = Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds()
val original = form(
start = LocalDateTime(LocalDate(2026, 11, 1), LocalTime(2, 30)),
end = LocalDateTime(LocalDate(2026, 11, 1), LocalTime(3, 30)),
).copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(end = FormTime(LocalDateTime(LocalDate(2026, 11, 1), LocalTime(3, 40))))
val values = update(original, updated, series = anchor)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(anchor)
}
@Test
fun `a series anchor moved between the repeated hours follows the form`() {
// Editing the anchor occurrence itself from 02:30 CEST onto 02:30 CET.
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val anchor = Instant.parse("2026-10-25T00:30:00Z").toEpochMilliseconds()
val original = form(start = repeated, end = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(4, 0)))
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), rrule = "FREQ=WEEKLY")
val updated = original.copy(start = FormTime(repeated, UtcOffset(hours = 1)))
val values = update(original, updated, series = anchor)
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
}
}
@@ -0,0 +1,12 @@
package de.jeanlucmakiola.calendula.data.time
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.TimeZone
/** A device zone a test sets, by default the machine's own. */
class FakeZoneSource(zone: TimeZone = TimeZone.currentSystemDefault()) : ZoneSource {
override val zone: MutableStateFlow<TimeZone> = MutableStateFlow(zone)
override fun current(): TimeZone = zone.value
}
@@ -560,4 +560,16 @@ class EventFormTest {
assertThat(moved.withTimezone(null, berlin).startInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
}
@Test
fun `an end picked in the spring-forward gap moves on as a start does`() {
val form = form(
start = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)),
end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 30)),
)
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
assertThat(picked.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
val started = form.withStart(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
assertThat(started.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
}
@@ -250,4 +250,17 @@ class EventShiftTest {
)
assertThat(summer.shiftedByDays(7, berlin).startInstant(berlin)).isEqualTo(first)
}
@Test
fun `a day move onto the spring-forward gap keeps its wall clock`() {
// Saturday 02:30 to 03:30, moved onto Sunday 29 March, when 02:30 is skipped.
val moved = form(
start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(2, 30)),
end = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(3, 30)),
).shiftedByDays(1, berlin)
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)))
// It still means 03:30 CEST, an hour long.
assertThat(moved.startInstant(berlin)).isEqualTo(Instant.parse("2026-03-29T01:30:00Z"))
assertThat(moved.endInstant(berlin)).isEqualTo(Instant.parse("2026-03-29T02:30:00Z"))
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
@@ -93,7 +94,7 @@ class RescheduleViewModelTest {
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
return RescheduleViewModel(repo, dispatcher)
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
}
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
@@ -276,4 +276,16 @@ class TimelineDropTest {
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
}
@Test
fun `a bottom resize to a time the grid draws once lands there`() {
// 01:00 CEST to 04:00 CET, its end pulled up to 02:15: only the first pass
// is drawn at 02:15, the second would be drawn down at 03:00.
val event = overnight.copy(
start = Instant.parse("2026-10-24T23:00:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z"))
}
}
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -102,10 +103,11 @@ class EventEditViewModelTest {
// Passed in by the tests that need to seed a setting first; a second
// DataStore on the same file would clash, so it is built only once.
s: SettingsPrefs = settings(tempDir),
zones: FakeZoneSource = FakeZoneSource(),
): EventEditViewModel {
val p = prefs(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
return EventEditViewModel(repo, p, s, dispatcher)
return EventEditViewModel(repo, p, s, dispatcher, zones)
}
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
@@ -423,7 +425,7 @@ class EventEditViewModelTest {
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val vm = viewModel(tempDir, fake).apply { deviceZone = { berlin } }
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
// An hour from 02:30 CEST ends at 02:30 CET, the same wall clock.
@@ -444,7 +446,7 @@ class EventEditViewModelTest {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setCalendarEventDuration(1L, 480)
val vm = viewModel(tempDir, fake, s).apply { deviceZone = { berlin } }
val vm = viewModel(tempDir, fake, s, FakeZoneSource(berlin))
val job = activate(vm)
// 02:30 CET to 03:00 CET: the default length mustn't stretch it.
@@ -468,7 +470,7 @@ class EventEditViewModelTest {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) }
}
val vm = viewModel(tempDir, fake).apply { deviceZone = { berlin } }
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds())
advanceUntilIdle()
@@ -482,4 +484,28 @@ class EventEditViewModelTest {
}
private val berlin = TimeZone.of("Europe/Berlin")
@Test
fun `all-day on and off keeps a second-pass event where it was`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val begin = Instant.parse("2026-10-25T01:30:00Z")
val end = Instant.parse("2026-10-25T02:30:00Z")
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) }
}
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds())
advanceUntilIdle()
val opened = vm.state.value!!.form!!
vm.setAllDay(true)
vm.setAllDay(false)
advanceUntilIdle()
assertThat(vm.state.value!!.form).isEqualTo(opened)
job.cancel()
}
}