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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user