diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index b19c23c..9750c7e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt index e11dabb..15f923d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt new file mode 100644 index 0000000..0d6052d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt @@ -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 + + /** 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 = 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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index fca1ba6..bb00bbc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt index 2f310c0..10dd027 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index 13868b0..669dcc5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -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(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() } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 30a1ba1..576f625 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 63e6153..ffe5362 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 9774c4e..f66a971 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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 = zoneSource.zone + .stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current()) private val _form = MutableStateFlow(null) private val _saveState = MutableStateFlow(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) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index fbc655a..d7b1752 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index cd26c22..c05c11a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -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 = zoneSource.zone + .stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current()) /** Whether to fade events that have already finished (display concern only). */ val dimCompletedEvents: StateFlow = 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, instances: List, 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 }, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index bf6aa94..4404914 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -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()) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/time/FakeZoneSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/time/FakeZoneSource.kt new file mode 100644 index 0000000..3a9a017 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/time/FakeZoneSource.kt @@ -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 = MutableStateFlow(zone) + + override fun current(): TimeZone = zone.value +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index 0d39262..2965f4c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -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))) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt index 438387e..3bef4c9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -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")) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index ef1fa46..87480ed 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -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 = diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt index 0d293b5..ff19ac0 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt @@ -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")) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index c3b6721..bc14943 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -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() + } }