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 4053588..8e64db5 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 @@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventForm import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDateTime +import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset +import java.time.LocalDateTime as JavaLocalDateTime /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ internal data class EventWriteTimes( @@ -35,7 +37,7 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa timezone = "UTC", ) } else { - val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone + val writeZone = writeZone(zone) EventWriteTimes( dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), @@ -43,6 +45,30 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa ) } +/** + * The zone this form's DTSTART is expressed in: UTC for an all-day event (the + * provider's date anchor), otherwise the form's own pinned zone, falling back to + * [deviceZone] when it doesn't pin one or pins something the tz database can't + * parse. + */ +private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) { + ZoneOffset.UTC +} else { + timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone +} + +/** + * The form's start as a bare wall-clock value — what the user sees on the form, + * stripped of any zone. All-day events use their date's midnight rather than + * [EventForm.start]'s placeholder time-of-day, which exists only so switching the + * event back to timed has something to show. + */ +private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) { + start.date.toJavaLocalDate().atStartOfDay() +} else { + start.toJavaLocalDateTime() +} + /** * RFC 2445 duration for a recurring event's row (the provider requires * DURATION instead of DTEND when an RRULE is set): whole days for all-day @@ -109,10 +135,12 @@ internal fun buildEventInsertValues( * Time fields travel together (the provider validates them as a unit): * - unchanged times, all-day flag and rrule → no time columns at all; * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; - * - recurring result → the *series* DTSTART moves by the same delta the user - * applied to the displayed occurrence ([seriesDtStartMillis] is the row's - * current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps - * past occurrences intact when someone edits a later occurrence's time. + * - recurring result → the *series* DTSTART moves by the same **wall-clock** + * shift the user applied to the displayed occurrence and is re-resolved in the + * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION + * replaces DTEND, RRULE is written. This keeps past occurrences intact when + * someone edits a later occurrence's time, and keeps the anchor's time-of-day + * stable across a DST boundary or a zone change between the two. */ internal fun buildEventUpdateValues( original: EventForm, @@ -158,8 +186,23 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.DURATION, null) } else { - val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis - put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta) + // Move the series anchor by the *wall-clock* shift the user applied to the + // displayed occurrence, then re-resolve it in the event's (possibly new) + // zone — never by a millisecond delta. An instant delta silently bakes in + // the offset that happened to apply on the edited occurrence's date, which + // 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 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)) + put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli()) put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.RRULE, updated.rrule) put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index c107099..d19e483 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -31,8 +31,33 @@ data class TimeZoneOption( /** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */ val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") + + /** + * The four fields [filterTimeZones] matches on, normalized once here rather + * than per query. Normalizing is not cheap — NFD decomposition plus a combining- + * mark strip — and a search re-examines every option on every keystroke, so + * doing it at construction turns ~2400 normalizations per character into a few + * hundred plain `startsWith`/`contains` calls. + * + * Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`: + * it is derived state, and two options with the same id are the same option. + */ + internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys( + city = city.normalizeForSearch(), + id = id.normalizeForSearch(), + displayName = displayName.normalizeForSearch(), + shortName = shortName.normalizeForSearch(), + ) } +/** Pre-normalized match targets for one [TimeZoneOption]. */ +internal data class TimeZoneSearchKeys( + val city: String, + val id: String, + val displayName: String, + val shortName: String, +) + private fun optionFor( zone: ZoneId, locale: Locale, @@ -87,9 +112,13 @@ private fun resolveAbbreviation( private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT") /** - * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it - * once and filter the result rather than rebuilding per keystroke. [regionOf] - * (see [resolveAbbreviation]) supplies the zone's region for the abbreviation. + * Every zone the JVM knows, resolved at [at]. [regionOf] (see + * [resolveAbbreviation]) supplies the zone's region for the abbreviation. + * + * This is ~600 entries, each costing a localized display name plus up to two ICU + * short-name lookups, so it is **not** cheap enough for the main thread — build + * it off-thread once and filter the result with [filterTimeZones] rather than + * rebuilding per keystroke. * * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are * dropped: they're aliases the tz database keeps for compatibility, they'd @@ -154,18 +183,15 @@ fun filterTimeZones(options: List, query: String): List - val city = option.city.normalizeForSearch() - val id = option.id.normalizeForSearch() - val name = option.displayName.normalizeForSearch() - val abbrev = option.shortName.normalizeForSearch() + val keys = option.searchKeys val rank = when { - city.startsWith(needle) -> 0 - abbrev == needle -> 1 - name.startsWith(needle) -> 2 - abbrev.startsWith(needle) -> 3 - city.contains(needle) -> 4 - id.contains(needle) -> 5 - name.contains(needle) -> 6 + keys.city.startsWith(needle) -> 0 + keys.shortName == needle -> 1 + keys.displayName.startsWith(needle) -> 2 + keys.shortName.startsWith(needle) -> 3 + keys.city.contains(needle) -> 4 + keys.id.contains(needle) -> 5 + keys.displayName.contains(needle) -> 6 else -> return@mapNotNull null } rank to option @@ -174,6 +200,9 @@ fun filterTimeZones(options: List, query: String): List, query: String): List, today: LocalDate, + zone: TimeZone, dimPast: Boolean, now: Instant, onEventClick: (EventInstance) -> Unit, @@ -379,6 +383,7 @@ private fun AgendaList( AgendaEventRow( event = event, day = day.date, + zone = zone, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), modifier = animateItemMotion(), @@ -458,6 +463,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) { private fun AgendaEventRow( event: EventInstance, day: LocalDate, + zone: TimeZone, position: Position, dimmed: Boolean, modifier: Modifier = Modifier, @@ -469,7 +475,7 @@ private fun AgendaEventRow( GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, title = title, - summary = agendaTimeSummary(event, day), + summary = agendaTimeSummary(event, day, zone), position = position, minHeight = 64.dp, leading = { @@ -575,25 +581,34 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { * An all-day multi-day event is simply "All day" on every day it covers. */ @Composable -private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { +private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { val is24Hour = LocalUse24HourFormat.current val locale = currentLocale() val time = when (val label = agendaTimeLabel(event, day, zone)) { AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) - is AgendaTimeLabel.Starts -> - stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale)) - is AgendaTimeLabel.Ends -> - stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale)) - is AgendaTimeLabel.Range -> - "${formatTime(label.start, is24Hour, locale)} – ${formatTime(label.end, is24Hour, locale)}" + is AgendaTimeLabel.Starts -> stringResource( + R.string.agenda_span_starts, + formatTime(label.start, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Ends -> stringResource( + R.string.agenda_span_ends, + formatTime(label.end, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + + formatTime(label.end, zone, is24Hour, locale) } val location = event.location?.takeIf { it.isNotBlank() } return if (location != null) "$time · $location" else time } -private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String { +private fun formatTime( + instant: Instant, + zone: TimeZone, + is24Hour: Boolean, + locale: Locale, +): String { val t = instant.toLocalDateTime(zone).time return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index b88b1a7..8bd3be8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -160,5 +160,12 @@ sealed interface AgendaUiState { val rangeEnd: LocalDate, /** Whether to show the top range bar — header + switcher (toggle, on by default). */ val showRangeBar: Boolean, + /** + * The zone [days] were grouped in. Carried in the state rather than + * re-read by the screen so labelling and grouping cannot disagree: an + * event's "Starts …/Ends …/All day" line is only correct relative to the + * same zone that decided which day it was filed under. + */ + val zone: TimeZone, ) : AgendaUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 924f6b8..d19271e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -172,6 +172,7 @@ class AgendaViewModel @Inject constructor( rangeIsOverride = params.rangeIsOverride, rangeEnd = rangeEnd, showRangeBar = params.showRangeBar, + zone = zone, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt index 3cb3422..8b87359 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt @@ -16,13 +16,19 @@ import java.util.Locale * sits directly above a grid that already says which year it is, and the year's * *absence* is itself the signal that you're in the current one — it appears the * moment you page out of it, which is when it starts carrying information. + * + * [forceYear] overrides that for titles whose [date] does not tell the whole + * story — a week view's title names only the month its *first* day falls in, so + * a week straddling New Year must still show the year even though [date] is in + * [currentYear]. */ fun formatCalendarTitle( date: java.time.LocalDate, locale: Locale, currentYear: Int, skeleton: String, + forceYear: Boolean = false, ): String { - val fields = if (date.year == currentYear) skeleton else skeleton + "y" + val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y" return localizedDateFormatter(locale, fields).format(date) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index fc5d472..fe06016 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -35,6 +36,7 @@ import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.filterTimeZones +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.components.FullScreenPicker @@ -43,6 +45,8 @@ import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.locale.currentLocale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Full-screen zone picker: a query field over every zone the JVM knows, with the @@ -64,14 +68,28 @@ fun TimeZonePickerDialog( onDismiss: () -> Unit, ) { var query by rememberSaveable { mutableStateOf("") } - // ~600 zones, each resolving a localized name and a DST-aware offset: build - // once per open, then filter the result per keystroke. val locale = currentLocale() - val allZones = remember(locale) { timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) } + // ~600 zones, each resolving a localized name and up to two ICU short names: + // far too much to run inside composition, so build it on a worker and let the + // list fill in a frame later. Filtering the built catalogue is cheap (its + // search keys are pre-normalized), so that stays here. + val allZones by produceState(initialValue = emptyList(), locale) { + value = withContext(Dispatchers.Default) { + timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) + } + } val matches = remember(allZones, query) { filterTimeZones(allZones, query) } val recentZones = remember(allZones, recents) { recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } } } + // Resolved on its own rather than looked up in the catalogue: it's one zone, + // so it costs nothing, and the device row can then render complete on the + // first frame instead of showing a bare id until the catalogue lands. + val deviceSummary = remember(deviceZoneId, locale) { + timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion) + ?.let { zoneDescriptor(it) } + ?: deviceZoneId + } val searching = query.isNotBlank() fun choose(zoneId: String?) { @@ -97,7 +115,7 @@ fun TimeZonePickerDialog( item(key = "device") { GroupedRow( title = stringResource(R.string.event_edit_timezone_device), - summary = zoneSummary(deviceZoneId, allZones), + summary = deviceSummary, position = Position.Alone, selected = selected == null, leading = { Icon(Icons.Default.Public, contentDescription = null) }, @@ -130,7 +148,10 @@ fun TimeZonePickerDialog( } } - if (searching && matches.isEmpty()) { + // allZones.isNotEmpty() gates this: until the catalogue lands there is + // simply nothing to match yet, and claiming "no time zone matches" + // for that frame would be wrong. + if (searching && matches.isEmpty() && allZones.isNotEmpty()) { item(key = "empty") { Text( text = stringResource(R.string.event_edit_timezone_none, query), @@ -252,9 +273,3 @@ private fun ZoneRow( onClick = onClick, ) } - -/** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */ -private fun zoneSummary(zoneId: String, allZones: List): String = - allZones.firstOrNull { it.id == zoneId } - ?.let { zoneDescriptor(it) } - ?: zoneId diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 639f7fd..2deeb4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -109,6 +109,7 @@ import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone +import kotlin.time.toJavaInstant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -463,8 +464,8 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi // Same hierarchy as the When card above — the label carries the card, // the time sits small beneath it. The local time is the one the reader // acts on, so the original must stay quieter than it, not compete. - val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) { - foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale) + val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) { + foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start) } foreignZone?.let { zoneOption -> Spacer(Modifier.height(gap)) @@ -789,11 +790,26 @@ private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel * pinned to a zone different from the device's — the cases where showing it * removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the * tz database doesn't know). + * + * Resolved *at [at]*, the event's own start, not at "now": the abbreviation and + * offset both move with DST, and the card prints them next to the event's time. + * Resolving at now would label a July event "CET · 10:00 AM" when read in + * January — an abbreviation that contradicts the time beside it. */ -private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? { +private fun foreignTimeZone( + tz: String?, + isAllDay: Boolean, + locale: Locale, + at: Instant, +): TimeZoneOption? { if (isAllDay || tz.isNullOrBlank()) return null if (tz == ZoneId.systemDefault().id) return null - return timeZoneOptionOf(tz, locale, regionOf = ::icuTimeZoneRegion) + return timeZoneOptionOf( + tz, + locale, + at = at.toJavaInstant(), + regionOf = ::icuTimeZoneRegion, + ) } /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ 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 3f5d920..74ec60b 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 @@ -47,7 +47,6 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune @@ -164,8 +163,10 @@ import kotlinx.datetime.isoDayNumber import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinDayOfWeek +import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime +import kotlin.time.toJavaInstant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -683,7 +684,13 @@ private fun EventEditContent( // whoever is reading it. Spell the local equivalent out rather than // leaving the user to do the offset arithmetic. Absent (null) unless // the event is pinned somewhere other than here. - form.timesIn(TimeZone.currentSystemDefault())?.let { (localStart, localEnd) -> + // 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 localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) { + form.timesIn(TimeZone.currentSystemDefault()) + } + localTimes?.let { (localStart, localEnd) -> Spacer(Modifier.height(2.dp)) Text( text = stringResource( @@ -741,8 +748,20 @@ private fun EventEditContent( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - val pinned = remember(form.timezone, locale) { - form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) } + // Resolved at the event's own start, not at "now": the + // abbreviation and offset shown ("CEST · GMT+02:00") must be + // the ones that apply when the event actually happens. + val pinned = remember(form.timezone, form.start, locale) { + form.timezone + ?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() } + ?.let { (id, zone) -> + timeZoneOptionOf( + id, + locale, + at = form.start.toInstant(zone).toJavaInstant(), + regionOf = ::icuTimeZoneRegion, + ) + } } Column(modifier = Modifier.weight(1f)) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 8b6c58e..de3bcbe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -213,10 +213,22 @@ class SettingsViewModel @Inject constructor( * imperatively via [LauncherNameManager] and held here in its own flow — the * main settings combine is already at its arity limit and this isn't a * DataStore flow anyway. + * + * Seeded with the manifest default and corrected off-thread rather than read + * in the initializer: `getComponentEnabledSetting` is a binder round-trip to + * system_server, and this ViewModel is constructed on the main thread while + * Settings is opening. The row it feeds is well below the fold, so the + * one-frame correction is never visible. */ - private val _launcherName = MutableStateFlow(launcherNameManager.current()) + private val _launcherName = MutableStateFlow(LauncherName.CALENDULA) val launcherName: StateFlow = _launcherName.asStateFlow() + init { + viewModelScope.launch { + _launcherName.value = withContext(io) { launcherNameManager.current() } + } + } + // Emitted when a picked font file couldn't be read as a font; the screen // surfaces it and the previous selection stays put. private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1) @@ -484,11 +496,20 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) } } - /** Switch the launcher label between "Calendula" and "Calendar" (issue #44). */ + /** + * Switch the launcher label between "Calendula" and "Calendar" (issue #44). + * The card highlights immediately, then settles on whatever the component + * state actually reports — the two `setComponentEnabledSetting` calls are + * binder round-trips, so they don't belong on the main thread either. + */ fun setLauncherName(name: LauncherName) { - launcherNameManager.set(name) - // Re-read so the UI reflects the actual component state, not an assumption. - _launcherName.value = launcherNameManager.current() + _launcherName.value = name + viewModelScope.launch { + _launcherName.value = withContext(io) { + launcherNameManager.set(name) + launcherNameManager.current() + } + } } fun setPastEventDisplay(mode: PastEventDisplay) { 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 bd44535..88b9a14 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 @@ -880,13 +880,21 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri * month of [weekStart] means a week straddling a boundary keeps the outgoing * month until it is fully gone — a week is seven contiguous days, so the earlier * month has a day in it exactly while [weekStart] is still inside it. That also - * makes the title depend on nothing but [weekStart], so it cannot drift with the - * direction you paged in from. + * makes the *month* depend on nothing but [weekStart], so it cannot drift with + * the direction you paged in from. + * + * The *year* is decided from the whole week, not just its start: a week running + * Dec 28 – Jan 3 has four of its seven visible columns in the new year, so + * "December" with no year would be actively misleading while the reader is + * looking straight at January dates. */ -private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String = - formatCalendarTitle( +private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String { + val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) + return formatCalendarTitle( date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), locale = locale, currentYear = currentYear, skeleton = "LLLL", + forceYear = weekEnd.year != weekStart.year, ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b8019e..bfb853d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,7 +110,6 @@ Recent All time zones No time zone matches “%1$s” - Use device zone 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 b606a15..596d35b 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 @@ -123,8 +123,18 @@ class EventWriteMapperTest { private val seriesStart = 1_700_000_000_000L - private fun update(original: EventForm, updated: EventForm): Map = - buildEventUpdateValues(original, updated, seriesStart, berlin) + private fun update( + original: EventForm, + updated: EventForm, + series: Long = seriesStart, + ): Map = buildEventUpdateValues(original, updated, series, berlin) + + /** The instant [local] names in [zoneId], as the provider would store it. */ + private fun instantAt(local: String, zoneId: String): Long = + java.time.LocalDateTime.parse(local) + .atZone(java.time.ZoneId.of(zoneId)) + .toInstant() + .toEpochMilli() @Test fun `pristine form produces no values`() { @@ -219,6 +229,77 @@ class EventWriteMapperTest { assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") } + @Test + fun `pinning a recurring event to another zone keeps the series wall clock`() { + // The regression this guards: the series DTSTART used to move by a + // millisecond delta measured at the *edited occurrence*. Here the series + // anchor sits in January (Berlin CET, +1) and the edited occurrence in + // July (Berlin CEST, +2), so the July delta is an hour off for January — + // the whole series would have drifted to 10:00 Tokyo. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + + val values = update(original, original.copy(timezone = "Asia/Tokyo"), series) + + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Asia/Tokyo") + // The anchor still reads 09:00 — now 09:00 in Tokyo, not 10:00. + assertThat(values[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-07T09:00", "Asia/Tokyo")) + } + + @Test + fun `a time edit moves the series anchor in wall clock across a DST boundary`() { + // Anchor in winter, edited occurrence in summer: pushing the occurrence + // one hour later must leave the anchor at 10:00 winter time, not at an + // instant that re-reads as 11:00 once the offset differs. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)), + ) + + assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-07T10:00", "Europe/Berlin")) + } + + @Test + fun `moving a recurring occurrence to another day shifts the anchor by whole days`() { + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)), + end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)), + ) + + assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin")) + } + + @Test + fun `switching a recurring event to all-day anchors the series on a UTC midnight`() { + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + + val values = update(original, original.copy(isAllDay = true), series) + + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC") + val dtStart = values[CalendarContract.Events.DTSTART] as Long + assertThat(dtStart % (24L * 60 * 60 * 1000)).isEqualTo(0L) + } + @Test fun `adding a recurrence keeps the times and writes rule plus duration`() { val original = form()