From 7634df3cff91590524bfaaf68dab05aa7ae9df75 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 15:31:54 +0200 Subject: [PATCH 01/11] feat(domain): let an event pin its own time zone (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every write sampled ZoneId.systemDefault() and stamped it into EVENT_TIMEZONE, so the column was real but only ever held the device's zone: an event synced from elsewhere could be read in its zone, never authored in one. Give EventForm a nullable `timezone`, where null keeps meaning "the device zone at save time" — so every existing call site behaves exactly as before — and a non-null value pins the event to a zone it then tracks across DST. toWriteTimes resolves the form's zone ahead of the device's; toEditForm pins only when the stored zone differs from the device's, and prefills such an event in its own zone so the form shows the wall-clock the event actually means. Two provider-contract bugs fall out of this: - Editing the time of a foreign-zone event rewrote EVENT_TIMEZONE to the device's. The instants stayed right, so nothing looked wrong, but the event silently stopped tracking its zone and would drift an hour at the next DST boundary. Only the timesChanged gate spared title-only edits. - A zone change with an untouched wall-clock is still a time change (the same 09:00 elsewhere is a different instant), so it now trips timesChanged and rewrites DTSTART instead of being dropped. All-day events keep carrying no zone at all: they're date-anchored, and the UTC midnights they normalise to are an anchor rather than a location. TimeZoneCatalog is pure JVM so the search ranking and DST-aware offsets stay plain JUnit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/EventWriteMapper.kt | 19 ++- .../calendula/domain/EventForm.kt | 54 +++++++- .../calendula/domain/TimeZoneCatalog.kt | 123 ++++++++++++++++++ .../data/calendar/EventWriteMapperTest.kt | 65 ++++++++- .../calendula/domain/EventFormTest.kt | 52 ++++++++ .../calendula/domain/TimeZoneCatalogTest.kt | 107 +++++++++++++++ docs/ARCHITECTURE.md | 24 ++++ 7 files changed, 433 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt 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 9758587..4053588 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 @@ -20,7 +20,11 @@ internal data class EventWriteTimes( /** * All-day events live at UTC midnights with an exclusive DTEND (the * CalendarContract convention — a one-day event ends at the next midnight); - * timed events resolve their wall-clock values in [zone]. + * timed events resolve their wall-clock values in the form's own + * [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin + * one. Passing the device zone is therefore still correct for an unpinned form — + * but it no longer overrides a pinned event's zone, which is what used to + * silently re-anchor a foreign-zone event to the device on any time edit. */ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) { EventWriteTimes( @@ -31,10 +35,11 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa timezone = "UTC", ) } else { + val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone EventWriteTimes( - dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), - dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), - timezone = zone.id, + dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), + dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), + timezone = writeZone.id, ) } @@ -134,10 +139,14 @@ internal fun buildEventUpdateValues( putAll(eventColorColumns(updated.colorKey, updated.color)) } + // A zone change counts as a time change even when the wall-clock is + // untouched: the same 09:00 in another zone is a different instant, so + // DTSTART has to move with it. val timesChanged = updated.start != original.start || updated.end != original.end || updated.isAllDay != original.isAllDay || - updated.rrule != original.rrule + updated.rrule != original.rrule || + updated.timezone != original.timezone if (!timesChanged) return@buildMap val newTimes = updated.toWriteTimes(zone) 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 5acc612..9054e20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -9,8 +9,8 @@ import kotlin.time.Instant /** * User input for creating an event (and, from v1.3, editing one). Times are - * wall-clock values in the device zone; the data layer translates them to - * provider millis (all-day events normalise to UTC midnights there). + * wall-clock values in [timezone]; the data layer translates them to provider + * millis (all-day events normalise to UTC midnights there). */ data class EventForm( val calendarId: Long?, @@ -18,6 +18,23 @@ data class EventForm( val isAllDay: Boolean = false, val start: LocalDateTime, val end: LocalDateTime, + /** + * The zone [start]/[end] are wall-clock values in, or null to follow the + * device — null is not "no zone", it is "whichever zone the device is in + * when this is saved", which is what an event authored and lived in one + * place wants. The data layer resolves it at write time and always stamps a + * concrete `EVENT_TIMEZONE`. + * + * A non-null value pins the event to a zone regardless of where the device + * is, so it keeps tracking that zone's offset across DST. [toEditForm] only + * sets it when the stored zone differs from the device's, so merely opening + * a local event never reveals the field — and re-opening a pinned one in + * another zone round-trips it rather than silently re-anchoring it. + * + * Always null for all-day events: those are date-anchored, not zone-anchored + * (see [EventFormField.Timezone] and the data layer's UTC-midnight rule). + */ + val timezone: String? = null, val location: String = "", val description: String = "", /** Reminder lead times in minutes before the start, deduplicated. */ @@ -66,11 +83,19 @@ data class EventAttendee( /** * The form's optional sections. Which ones show by default is a user setting; - * the rest unfold behind a "more fields" button. + * the rest unfold behind a "more fields" button. Declaration order is the order + * they're offered in, so a new constant goes where it belongs on the form, not + * at the end. */ enum class EventFormField { Location, Description, + /** + * Pins the event's wall-clock times to a zone. Offered right after the time + * fields it qualifies, and suppressed entirely for all-day events, whose + * dates are deliberately zone-free. + */ + Timezone, Reminders, Recurrence, Availability, @@ -100,8 +125,25 @@ enum class EventFormProblem { * All-day provider times are UTC midnights with an exclusive end; the form * shows the last covered day and keeps placeholder wall-clock times in case * the user switches the event to timed. + * + * A timed event stored in a zone other than [zone] is prefilled *in its own + * zone* and keeps it pinned, so the wall-clock the form shows is the one the + * event means ("the New York 09:00 call") and a later save re-anchors it to the + * same zone rather than the device's. */ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { + // All-day events are date-anchored and carry a nominal "UTC" that is an + // anchor, not a location, so they never pin a zone. + val pinnedZone = if (instance.isAllDay) { + null + } else { + eventTimezone + ?.takeIf { it != zone.id } + // An unparseable id (a malformed sync row) can't be honoured or + // shown; fall back to the device zone rather than failing the open. + ?.takeIf { runCatching { TimeZone.of(it) }.isSuccess } + } + val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone val (start, end) = if (instance.isAllDay) { val startDate = Instant.fromEpochMilliseconds(beginMillis) .toLocalDateTime(TimeZone.UTC).date @@ -110,8 +152,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1)) LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) } else { - Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to - Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone) + Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to + Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone) } return EventForm( calendarId = instance.calendarId, @@ -119,6 +161,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): isAllDay = instance.isAllDay, start = start, end = end, + timezone = pinnedZone, location = instance.location.orEmpty(), description = description.orEmpty(), reminders = reminders.map { it.minutes }.distinct().sorted(), @@ -184,6 +227,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon fun EventForm.populatedFields(): Set = buildSet { if (location.isNotBlank()) add(EventFormField.Location) if (description.isNotBlank()) add(EventFormField.Description) + if (timezone != null) add(EventFormField.Timezone) if (reminders.isNotEmpty()) add(EventFormField.Reminders) if (rrule != null) add(EventFormField.Recurrence) if (availability != Availability.Busy) add(EventFormField.Availability) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt new file mode 100644 index 0000000..ca08fc0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -0,0 +1,123 @@ +package de.jeanlucmakiola.calendula.domain + +import java.text.Normalizer +import java.time.Instant +import java.time.ZoneId +import java.time.format.TextStyle +import java.util.Locale + +/** + * One selectable zone, resolved for display: [id] is the IANA id we store in + * `EVENT_TIMEZONE`, [displayName] its localized name, and [offsetMinutes] its + * offset *at a given instant* — zones shift with DST, so an offset is only + * meaningful next to the moment it was resolved for. + */ +data class TimeZoneOption( + val id: String, + val displayName: String, + val offsetMinutes: Int, +) { + /** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */ + val city: String get() = id.substringAfterLast('/').replace('_', ' ') + + /** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */ + val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") +} + +/** + * Every zone the JVM knows, localized and resolved at [at]. This is ~600 + * entries, so build it once and filter the result 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 + * double up the real zones in the list, and none of them is what a user means + * when they pick a place. + */ +fun timeZoneOptions( + locale: Locale = Locale.getDefault(), + at: Instant = Instant.now(), +): List = ZoneId.getAvailableZoneIds() + .asSequence() + .filter { it.contains('/') && !it.startsWith("SystemV/") } + .map { id -> + val zone = ZoneId.of(id) + TimeZoneOption( + id = id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, + ) + } + .sortedWith(compareBy({ it.region }, { it.city })) + .toList() + +/** + * Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz + * database doesn't know it — for labelling one known zone without paying to + * build the whole catalogue. + */ +fun timeZoneOptionOf( + id: String, + locale: Locale = Locale.getDefault(), + at: Instant = Instant.now(), +): TimeZoneOption? { + val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null + return TimeZoneOption( + id = id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, + ) +} + +/** + * [options] matching [query], best matches first; a blank query returns the list + * unchanged. Matching is accent- and case-insensitive and treats underscores as + * spaces, so "sao paulo" finds "America/Sao_Paulo". + * + * Ranking puts a city that *starts with* the query above one that merely + * contains it — typing "col" should reach Colombo before Turks_and_Caicos — + * and the id is matched ahead of the localized name so a user who knows the + * IANA id gets it first. + */ +fun filterTimeZones(options: List, query: String): List { + val needle = query.normalizeForSearch() + if (needle.isEmpty()) return options + return options + .mapNotNull { option -> + val city = option.city.normalizeForSearch() + val id = option.id.normalizeForSearch() + val name = option.displayName.normalizeForSearch() + val rank = when { + city.startsWith(needle) -> 0 + name.startsWith(needle) -> 1 + city.contains(needle) -> 2 + id.contains(needle) -> 3 + name.contains(needle) -> 4 + else -> return@mapNotNull null + } + rank to option + } + .sortedWith(compareBy({ it.first }, { it.second.city })) + .map { it.second } +} + +/** + * Lowercased, accent-stripped, underscores and slashes flattened to spaces, so + * a query types the way a place is spoken rather than the way the tz database + * spells it. + */ +private fun String.normalizeForSearch(): String = + Normalizer.normalize(this, Normalizer.Form.NFD) + .replace(Regex("\\p{Mn}+"), "") + .replace('_', ' ') + .replace('/', ' ') + .lowercase(Locale.ROOT) + .trim() + +/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */ +fun formatGmtOffset(offsetMinutes: Int): String { + if (offsetMinutes == 0) return "GMT" + val sign = if (offsetMinutes < 0) '-' else '+' + val abs = kotlin.math.abs(offsetMinutes) + return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60) +} 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 972b9d9..b606a15 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 @@ -19,7 +19,14 @@ class EventWriteMapperTest { isAllDay: Boolean = false, start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)), - ): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end) + timezone: String? = null, + ): EventForm = EventForm( + calendarId = 1L, + isAllDay = isAllDay, + start = start, + end = end, + timezone = timezone, + ) @Test fun `timed event resolves wall clock in the given zone`() { @@ -30,6 +37,26 @@ class EventWriteMapperTest { assertThat(times.timezone).isEqualTo("Europe/Berlin") } + @Test + fun `pinned zone wins over the device zone`() { + val times = form(timezone = "America/New_York").toWriteTimes(berlin) + // 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give. + assertThat(times.timezone).isEqualTo("America/New_York") + assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L) + } + + @Test + fun `an unparseable pinned zone falls back to the device zone`() { + val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("Europe/Berlin") + } + + @Test + fun `all-day ignores a pinned zone and stays UTC`() { + val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("UTC") + } + @Test fun `all-day event lives at UTC midnights with exclusive end`() { val times = form(isAllDay = true).toWriteTimes(berlin) @@ -105,6 +132,42 @@ class EventWriteMapperTest { assertThat(update(original, original.copy())).isEmpty() } + @Test + fun `editing the time of a pinned event keeps its zone`() { + // The regression this guards: the update used to stamp the device zone + // over the event's own, silently un-anchoring a foreign-zone event so it + // stopped tracking that zone across DST. + val original = form(timezone = "America/New_York") + val values = update(original, original.copy(title = "Standup", start = original.start)) + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE) + + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)), + ) + assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE]) + .isEqualTo("America/New_York") + } + + @Test + fun `changing only the zone still moves the event`() { + // Same wall-clock, different zone: the instant moves, so DTSTART must be + // rewritten even though start/end compare equal. + val original = form() + val values = update(original, original.copy(timezone = "America/New_York")) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York") + // 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later. + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L) + } + + @Test + fun `unpinning back to the device zone rewrites the times`() { + val original = form(timezone = "America/New_York") + val values = update(original, original.copy(timezone = null)) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) + } + @Test fun `text-only edit writes just the changed columns`() { val original = form() 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 fc975c6..a09a1b8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -117,6 +117,7 @@ class EventFormTest { attendees: List = emptyList(), eventColor: Int? = null, eventColorKey: String? = null, + eventTimezone: String? = null, ): EventDetail = EventDetail( instance = EventInstance( instanceId = 1L, @@ -138,6 +139,7 @@ class EventFormTest { accessLevel = accessLevel, eventColor = eventColor, eventColorKey = eventColorKey, + eventTimezone = eventTimezone, ) @Test @@ -157,6 +159,56 @@ class EventFormTest { assertThat(prefilled.description).isEqualTo("Body") } + @Test + fun `toEditForm leaves an event in the device zone unpinned`() { + val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + // Same zone as the device: pinning it would only make the picker appear + // on every ordinary event. + assertThat(prefilled.timezone).isNull() + assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone) + } + + @Test + fun `toEditForm pins a foreign zone and shows the times in it`() { + val prefilled = detail(eventTimezone = "America/New_York").toEditForm( + beginMillis = 1_781_164_800_000L, // 08:00Z + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + assertThat(prefilled.timezone).isEqualTo("America/New_York") + // 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the + // event's own wall-clock, which is what a later save re-anchors to. + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0))) + assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone) + } + + @Test + fun `toEditForm never pins a zone on an all-day event`() { + // All-day rows carry a nominal "UTC" that is an anchor, not a location. + val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm( + beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L, + endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L, + zone = berlin, + ) + assertThat(prefilled.timezone).isNull() + } + + @Test + fun `toEditForm ignores an unparseable stored zone`() { + val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + // A malformed sync row must not be honoured, nor fail the open. + assertThat(prefilled.timezone).isNull() + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) + } + @Test fun `toEditForm turns the exclusive all-day end into the last covered day`() { // 11th..13th = UTC midnights of the 11th and the (exclusive) 14th. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt new file mode 100644 index 0000000..fbecfc5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.Locale + +class TimeZoneCatalogTest { + + // A fixed instant so DST-dependent offsets can't drift with the wall clock: + // 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT. + private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z") + private val zones = timeZoneOptions(Locale.ENGLISH, summer) + + private fun filter(query: String) = filterTimeZones(zones, query) + + @Test + fun `catalogue holds the real zones and drops the legacy aliases`() { + assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York") + // Bare aliases the tz database keeps for compatibility would double up + // the real zones in the picker. + assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC") + assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue() + } + + @Test + fun `option exposes city and region split from the id`() { + val ny = zones.first { it.id == "America/New_York" } + assertThat(ny.city).isEqualTo("New York") + assertThat(ny.region).isEqualTo("America") + } + + @Test + fun `offset is resolved at the given instant, not the current one`() { + val berlin = zones.first { it.id == "Europe/Berlin" } + // CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST. + assertThat(berlin.offsetMinutes).isEqualTo(120) + + val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z")) + assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60) + } + + @Test + fun `blank query returns everything unchanged`() { + assertThat(filter("")).isEqualTo(zones) + assertThat(filter(" ")).isEqualTo(zones) + } + + @Test + fun `query matches the city, ignoring case and underscores`() { + assertThat(filter("new york").map { it.id }).contains("America/New_York") + assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York") + assertThat(filter("new_york").map { it.id }).contains("America/New_York") + } + + @Test + fun `query matches accented cities typed plainly`() { + // Sao_Paulo has no accent in the id, but its localized name does — the + // point is that a user typing plain ASCII still finds it. + assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo") + assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich") + } + + @Test + fun `query matches the full IANA id`() { + assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin") + } + + @Test + fun `a city starting with the query outranks one merely containing it`() { + val ids = filter("york").map { it.id } + // "New York" contains "york"; nothing starts with it, so it should still + // surface rather than being buried. + assertThat(ids).contains("America/New_York") + + // "col" starts Colombo but only appears mid-string elsewhere. + val col = filter("col").map { it.id } + assertThat(col.first()).isEqualTo("Asia/Colombo") + } + + @Test + fun `no match yields an empty list rather than everything`() { + assertThat(filter("zzzznotazone")).isEmpty() + } + + @Test + fun `single zone resolves the same way the catalogue does`() { + val fromCatalogue = zones.first { it.id == "Europe/Berlin" } + assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer)) + .isEqualTo(fromCatalogue) + } + + @Test + fun `an unknown zone id resolves to null`() { + assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull() + } + + @Test + fun `gmt offsets format with a sign and padding`() { + assertThat(formatGmtOffset(0)).isEqualTo("GMT") + assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00") + assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00") + // India is +05:30 — a whole-hour assumption would render this wrong. + assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30") + assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21d1f42..a8c3600 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,6 +99,30 @@ on-device — see plan 03): UTC, so zones ahead of UTC can't leak an extra occurrence. - All-day events are normalised to UTC midnights with an exclusive end. +### Event time zones + +`EventForm.timezone` is the zone its wall-clock times mean, and **null means +"the device zone at save time"** — not "no zone". The data layer resolves it in +`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary +event behaves exactly as it did before the field existed. + +- A non-null value **pins** the event: it keeps tracking that zone's offset + across DST no matter where the device is. `toEditForm` only pins when the + stored zone differs from the device's, so the optional Time-zone field stays + hidden on ordinary events and reveals itself (via `populatedFields`) on + foreign-zone ones. +- A pinned event is prefilled **in its own zone**, so the form shows the + wall-clock the event means rather than the device's rendering of it. +- A zone change counts as a **time change** even with the wall-clock untouched + (same 09:00 elsewhere is a different instant), so `buildEventUpdateValues` + includes it in `timesChanged` and rewrites `DTSTART`. +- **All-day events never carry a zone.** They're date-anchored — the UTC + midnights above are an anchor, not a location — so the field is withheld from + the form entirely and `toWriteTimes` forces `"UTC"` regardless. + +Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and +`AllDayReminderEncoding`'s offset (see its KDoc). + ## Save conflicts No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form From fefe1f3652afc272cc145a21db04c1721f928136 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 15:32:04 +0200 Subject: [PATCH 02/11] feat(edit): add a searchable time-zone picker (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the zone as an optional form field, right after the time fields it qualifies: hidden on ordinary events, revealed automatically when the event already carries a foreign zone, and withheld entirely while all-day is on (a date-anchored event has no zone to show). The picker is a full-screen one per the app's convention, but it can't reuse OptionPicker: that composes every option eagerly, and ~600 zones would all compose on open. It drives its own LazyColumn instead, which needs the kit's new `scrollable = false` — the scaffold's own verticalScroll would otherwise throw on a nested same-axis scrollable. The device zone and recently-picked zones pin to the top so the common case needs no typing; search is accent- and case-insensitive. Recents persist in DataStore, capped at five, dropping ids the tz database no longer knows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 32 +++ .../ui/common/EventFormFieldVisuals.kt | 3 + .../calendula/ui/common/TimeZonePicker.kt | 203 ++++++++++++++++++ .../calendula/ui/edit/EventEditScreen.kt | 53 +++++ .../calendula/ui/edit/EventEditUiState.kt | 2 + .../calendula/ui/edit/EventEditViewModel.kt | 31 ++- app/src/main/res/values/strings.xml | 9 + floret-kit | 2 +- 8 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index cb4e610..2b0d8ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN +import java.time.ZoneId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -354,6 +355,26 @@ class SettingsPrefs @Inject constructor( } } + /** + * Zones the user picked recently, most recent first — the timezone picker's + * default list, since scrolling ~600 zones to re-find the two you actually + * use is the whole problem. Stored comma-joined (IANA ids never contain a + * comma); unparseable ids are dropped on read, so a zone the tz database + * later retires can't wedge the list. + */ + val recentTimeZones: Flow> = store.data.map { prefs -> + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]) + } + + suspend fun addRecentTimeZone(zoneId: String) { + store.edit { prefs -> + val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])) + .distinct() + .take(MAX_RECENT_TIME_ZONES) + prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",") + } + } + /** * Whether opening the new-event form focuses the title field and raises the * keyboard straight away (issue #10). Default ON — a new event almost always @@ -650,6 +671,13 @@ class SettingsPrefs @Inject constructor( private fun titleTemplateKey(type: SpecialDateType) = stringPreferencesKey("special_dates_title_${type.name}") + private fun parseRecentTimeZones(stored: String?): List = + stored?.split(',').orEmpty() + .map { it.trim() } + .filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess } + .distinct() + .take(MAX_RECENT_TIME_ZONES) + private fun parseFormFields(stored: String?): Set = when (stored) { null -> DEFAULT_FORM_FIELDS else -> stored.split(',') @@ -736,6 +764,10 @@ class SettingsPrefs @Inject constructor( stringPreferencesKey("per_calendar_allday_reminder_override") internal val DEFAULT_FORM_FIELDS = setOf(EventFormField.Location, EventFormField.Description) + internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones") + + /** Enough to cover the zones a user actually recurs to, without a wall of rows. */ + internal const val MAX_RECENT_TIME_ZONES = 5 internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled") internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt index aa07543..91d84a5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.ui.graphics.vector.ImageVector import de.jeanlucmakiola.calendula.R @@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField fun eventFormFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description + EventFormField.Timezone -> R.string.event_detail_timezone EventFormField.Reminders -> R.string.event_detail_reminders EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability @@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) { fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Location -> Icons.Default.Place EventFormField.Description -> Icons.AutoMirrored.Filled.Notes + EventFormField.Timezone -> Icons.Default.Public EventFormField.Reminders -> Icons.Default.Notifications EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Availability -> Icons.Default.EventAvailable 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 new file mode 100644 index 0000000..4b33d87 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -0,0 +1,203 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.formatGmtOffset +import de.jeanlucmakiola.calendula.domain.timeZoneOptions +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.locale.currentLocale + +/** + * Full-screen zone picker: a query field over every zone the JVM knows, with the + * device zone and recently-picked ones pinned on top so the common case needs no + * typing. [selected] is the pinned zone id, or null when the event follows the + * device — picking the device row reports null back, which is what keeps an + * unpinned event unpinned rather than freezing it to today's zone. + * + * Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this + * drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far + * past what an eagerly composed column can carry. + */ +@Composable +fun TimeZonePickerDialog( + selected: String?, + deviceZoneId: String, + recents: List, + onSelect: (String?) -> Unit, + 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) } + val matches = remember(allZones, query) { filterTimeZones(allZones, query) } + val recentZones = remember(allZones, recents) { + recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } } + } + val searching = query.isNotBlank() + + fun choose(zoneId: String?) { + onSelect(zoneId) + onDismiss() + } + + FullScreenPicker( + title = stringResource(R.string.event_detail_timezone), + onDismiss = onDismiss, + scrollable = false, + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text(stringResource(R.string.event_edit_timezone_search)) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { + if (!searching) { + // The device row is the "unpinned" choice, so it reads as a mode + // rather than as a zone among zones — it stays out of the big + // list and never carries an offset. + item(key = "device") { + GroupedRow( + title = stringResource(R.string.event_edit_timezone_device), + summary = zoneSummary(deviceZoneId, allZones), + position = Position.Alone, + selected = selected == null, + leading = { Icon(Icons.Default.Public, contentDescription = null) }, + trailing = if (selected == null) { + { SelectedCheck() } + } else { + null + }, + onClick = { choose(null) }, + ) + } + if (recentZones.isNotEmpty()) { + item(key = "recent_header") { + SectionHeader(stringResource(R.string.event_edit_timezone_recent)) + } + itemsIndexed( + items = recentZones, + key = { _, zone -> "recent_${zone.id}" }, + ) { index, zone -> + ZoneRow( + zone = zone, + position = positionOf(index, recentZones.size), + selected = zone.id == selected, + onClick = { choose(zone.id) }, + ) + } + } + item(key = "all_header") { + SectionHeader(stringResource(R.string.event_edit_timezone_all)) + } + } + + if (searching && matches.isEmpty()) { + item(key = "empty") { + Text( + text = stringResource(R.string.event_edit_timezone_none, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 48.dp), + ) + } + } + + itemsIndexed( + items = matches, + key = { _, zone -> "zone_${zone.id}" }, + ) { index, zone -> + ZoneRow( + zone = zone, + position = positionOf(index, matches.size), + selected = zone.id == selected, + onClick = { choose(zone.id) }, + ) + } + } + } +} + +/** A small primary-coloured group label, matching the settings screens. */ +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun SelectedCheck() { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun ZoneRow( + zone: TimeZoneOption, + position: Position, + selected: Boolean, + onClick: () -> Unit, +) { + GroupedRow( + title = zone.city, + summary = "${zone.displayName} · ${formatGmtOffset(zone.offsetMinutes)}", + position = position, + selected = selected, + trailing = if (selected) { + { SelectedCheck() } + } else { + null + }, + onClick = onClick, + ) +} + +/** "Central European Time · GMT+02:00" for [zoneId], or the bare id if unknown. */ +private fun zoneSummary(zoneId: String, allZones: List): String = + allZones.firstOrNull { it.id == zoneId } + ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + ?: zoneId 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 11ec532..67e47a0 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,6 +47,7 @@ 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 @@ -111,6 +112,8 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.formatGmtOffset +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceFreq @@ -126,6 +129,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow +import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.DialogAmountField @@ -160,6 +164,7 @@ import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle @@ -506,6 +511,7 @@ private fun EventEditContent( var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } var showColorPicker by rememberSaveable { mutableStateOf(false) } + var showTimezonePicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } // Pick a guest from contacts: a one-shot system picker returning the chosen @@ -706,6 +712,43 @@ private fun EventEditContent( // Optional sections: which ones render by default is a setting; the // rest unfold behind the "more fields" button below. + OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Default.Public, + iconContentDescription = null, + onClick = { showTimezonePicker = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + val pinned = remember(form.timezone, locale) { + form.timezone?.let { timeZoneOptionOf(it, locale) } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = pinned?.city + ?: stringResource(R.string.event_edit_timezone_device), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = pinned + ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + ?: stringResource(R.string.event_edit_timezone_device_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + OptionalFormSection(visible = EventFormField.Location in state.visibleFields) { Spacer(Modifier.height(gap)) EditCard( @@ -1081,6 +1124,16 @@ private fun EventEditContent( ) } + if (showTimezonePicker) { + TimeZonePickerDialog( + selected = form.timezone, + deviceZoneId = ZoneId.systemDefault().id, + recents = state.recentTimeZones, + onSelect = viewModel::setTimezone, + onDismiss = { showTimezonePicker = false }, + ) + } + if (showColorPicker) { ColorPickerDialog( palette = state.colorPalette, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 934b792..6a68d04 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -27,6 +27,8 @@ data class EventEditUiState( * neither list. */ val hiddenFields: List = emptyList(), + /** Recently picked zones, most recent first — the zone picker's shortlist. */ + val recentTimeZones: List = emptyList(), /** True while editing an existing event (the calendar is then fixed). */ val isEditing: Boolean = false, /** 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 8308538..b339f39 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 @@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -219,7 +220,8 @@ class EventEditViewModel @Inject constructor( ).flowOn(io), colorPalette, allCalendars, - ) { local, external, palette, allCalendars -> + settingsPrefs.recentTimeZones.flowOn(io), + ) { local, external, palette, allCalendars, recentTimeZones -> val form = local.form ?: return@combine null val resolvedId = form.calendarId ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } @@ -235,14 +237,19 @@ class EventEditViewModel @Inject constructor( val pickerCalendars = if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar else external.writable - val visibleFields = external.defaultFields + local.revealed + // An all-day event is date-anchored, so a zone is meaningless on it — + // the field is withheld from both lists rather than shown as a no-op. + val offerableFields = EventFormField.entries.toSet() - + if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet() + val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields EventEditUiState( form = resolved, calendars = pickerCalendars, problems = if (local.showProblems) resolved.problems() else emptySet(), saveState = local.saveState, visibleFields = visibleFields, - hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), + hiddenFields = (offerableFields - visibleFields).sorted(), + recentTimeZones = recentTimeZones, isEditing = local.editTarget != null, isManaged = isManaged, autofocusTitle = external.autofocusTitle, @@ -453,12 +460,28 @@ class EventEditViewModel @Inject constructor( fun setLocation(value: String) = update { it.copy(location = value) } fun setDescription(value: String) = update { it.copy(description = value) } fun setAllDay(value: Boolean) { - update { it.copy(isAllDay = value) } + // Going all-day drops any pinned zone: the times become bare dates that + // 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. + update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) } // The default reminder differs for all-day vs timed; re-apply the // type-appropriate default unless the user has hand-edited it (guarded). applyDefaultReminder() } + /** + * Pin the event to [zoneId], or pass null to follow the device. The zone + * rides along as a recent so the picker can offer it without a search next + * time — only real picks are remembered, not the device fallback. + */ + fun setTimezone(zoneId: String?) { + update { it.copy(timezone = zoneId) } + if (zoneId != null) { + viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } } + } + } + /** * Switching calendars drops any chosen colour: a palette key is * account-scoped, and a raw colour may be invalid on the new calendar. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06f995d..eb35d31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,15 @@ Availability Visibility + + Device time zone + Follows wherever you are + Search time zones + Recent + All time zones + No time zone matches “%1$s” + Use device zone + Color Calendar color diff --git a/floret-kit b/floret-kit index a50878a..3aa4cea 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit a50878a7ac638e725984e9cf447d693c711f52c4 +Subproject commit 3aa4ceada5af0c103d8f03263612524b466f9d19 From ab3df639b98ed2012d2ca27b4f73d6f243f145f3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 15:37:56 +0200 Subject: [PATCH 03/11] chore: pin floret-kit to v0.2.1 The time-zone picker needs the scaffold's `scrollable` opt-out, which landed in 0.2.1. Pins the tag rather than the branch commit the work was developed against, keeping the tag-pinning convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 3aa4cea..df8bdba 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 3aa4ceada5af0c103d8f03263612524b466f9d19 +Subproject commit df8bdbaf73380a354b7d2ee28e08875e155c89cd From 0588609c757d88cc1d2fc9c3ee266cc250533c3c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 15:54:33 +0200 Subject: [PATCH 04/11] fix(edit): use the family's text input, and show both times when zones differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes. The zone picker's search box was a raw Material OutlinedTextField — the only one left in the app, and against the convention DialogControls states outright ("the family's InlineTextField over a tonal surface, not Material's outlined field"). Rebuild it on InlineTextField over a tonal surface, with the clear button inside the surface since the picker's top bar is the title rather than a search field. Showing a pinned event only in its own zone answered "what was it set to?" while dropping "when is it for me?" — the user had to do the offset arithmetic. Show both whenever they differ: - the edit form keeps editing the event in its own zone (that's the time it was set at) and captions it with the local equivalent; - the detail screen keeps local times primary and now leads the zone card with the original ("8:00 AM – 9:00 AM in New York") instead of naming the zone and nothing else. EventForm.timesIn is pure, so the conversion — including crossing the date line and each zone's own DST, which don't move together — is a plain JUnit test rather than something only reviewable on a phone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/EventForm.kt | 18 +++++ .../calendula/ui/common/TimeZonePicker.kt | 77 ++++++++++++++++--- .../calendula/ui/detail/EventDetailScreen.kt | 31 +++++++- .../calendula/ui/edit/EventEditScreen.kt | 32 ++++++++ app/src/main/res/values/strings.xml | 7 ++ .../calendula/domain/EventFormTest.kt | 61 +++++++++++++++ 6 files changed, 214 insertions(+), 12 deletions(-) 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 9054e20..3d0fde7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -4,6 +4,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant @@ -219,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon rowEnd = instance.end, ) +/** + * The form's times as they land in [target] — what a pinned event's wall-clock + * actually means where the user is standing. Null when there's nothing to + * disambiguate: an unpinned event (already in [target]), one pinned to [target] + * itself, an all-day event (no zone), or an unparseable pinned zone. + * + * The form edits a pinned event in its own zone, so this is what lets the UI + * show the other side of the pair rather than making the user do the arithmetic. + */ +fun EventForm.timesIn(target: TimeZone): Pair? { + if (isAllDay) return null + val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null + if (pinned.id == target.id) return null + return start.toInstant(pinned).toLocalDateTime(target) to + end.toInstant(pinned).toLocalDateTime(target) +} + /** * The optional sections that hold a value in [form] — when editing, these * must be visible regardless of the user's default-fields setting, or the 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 4b33d87..4e1579d 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 @@ -2,17 +2,21 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -20,8 +24,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R @@ -31,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.formatGmtOffset import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.locale.currentLocale @@ -75,15 +84,10 @@ fun TimeZonePickerDialog( onDismiss = onDismiss, scrollable = false, ) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - singleLine = true, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - placeholder = { Text(stringResource(R.string.event_edit_timezone_search)) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + SearchField( + query = query, + onQueryChange = { query = it }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { if (!searching) { @@ -155,6 +159,59 @@ fun TimeZonePickerDialog( } } +/** + * The query box: the family's borderless [InlineTextField] over a tonal surface, + * so it reads like the rest of the app's inputs rather than a boxed Material + * field. The clear button rides inside the surface (unlike the search screen, + * which has a top bar to put it in) because the picker's bar is the title. + */ +@Composable +private fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val keyboard = LocalSoftwareKeyboardController.current + // surfaceContainerHighest — the picker sits on surfaceContainerHigh, so + // anything lower vanishes into it. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(28.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + InlineTextField( + value = query, + onValueChange = onQueryChange, + placeholder = stringResource(R.string.event_edit_timezone_search), + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Search, + onImeAction = { keyboard?.hide() }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 14.dp), + ) + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + /** A small primary-coloured group label, matching the settings screens. */ @Composable private fun SectionHeader(text: String) { 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 3d148fe..da5ccb8 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 @@ -453,14 +453,41 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi } // Time zone — only when the event is timed and pinned to a zone other - // than the device's, so cross-zone events read unambiguously. + // than the device's, so cross-zone events read unambiguously. The card + // above already answers "when is this for me"; this one answers "when + // was it set", which the zone's name alone never did — an 8 AM New York + // call showing as 2 PM here should still say 8 AM somewhere. foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> Spacer(Modifier.height(gap)) DetailCard( icon = Icons.Default.Public, iconContentDescription = stringResource(R.string.event_detail_timezone), ) { - Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) + val originalZone = detail.eventTimezone?.let { tz -> + runCatching { TimeZone.of(tz) }.getOrNull() + } + if (originalZone != null) { + // formatWhen puts the time range in the secondary half only + // for a same-day event; one spanning midnight — which a + // cross-zone event easily does — carries the whole span in + // the primary instead, so fall back to it rather than + // dropping the original time entirely. + val (primary, secondary) = formatWhen(instance, originalZone, locale) + Text( + text = stringResource( + R.string.event_detail_timezone_original, + secondary ?: primary, + originalZone.id.substringAfterLast('/').replace('_', ' '), + ), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(2.dp)) + } + Text( + text = tzLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } 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 67e47a0..97993d2 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 @@ -114,6 +114,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.formatGmtOffset import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf +import de.jeanlucmakiola.calendula.domain.timesIn import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceFreq @@ -676,6 +677,22 @@ private fun EventEditContent( color = MaterialTheme.colorScheme.error, ) } + // The rows above edit a pinned event in *its own* zone, which is the + // time it was set at — but that says nothing about when it lands for + // 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) -> + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource( + R.string.event_edit_timezone_local_time, + formatTimeRange(localStart, localEnd, locale), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Spacer(Modifier.height(gap)) @@ -1986,6 +2003,21 @@ private fun EditCard( } } +/** + * "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one + * time when both land on the same minute (an instant event shouldn't read as a + * range against itself). Dates are deliberately absent — the rows above carry + * them. + */ +@Composable +private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String { + val use24Hour = LocalUse24HourFormat.current + val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) } + val from = timeFormat.format(start.time.toJavaLocalTime()) + val to = timeFormat.format(end.time.toJavaLocalTime()) + return if (from == to) from else "$from – $to" +} + /** * Borderless text input used inside the cards (and as the headline title). * Thin wrapper over the shared [InlineTextField] so the form and the rest of diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb35d31..16cfe3d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,13 @@ All time zones No time zone matches “%1$s” Use device zone + + %1$s your time + + %1$s in %2$s Color 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 a09a1b8..b2fb058 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -209,6 +209,67 @@ class EventFormTest { assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) } + @Test + fun `timesIn converts a pinned event into the target zone`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + timezone = "America/New_York", + ) + val (start, end) = form.timesIn(berlin)!! + // 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2). + assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0))) + assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0))) + } + + @Test + fun `timesIn crosses the date line when the offset pushes past midnight`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)), + timezone = "America/New_York", + ) + val (start, _) = form.timesIn(berlin)!! + // 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move + // with it, not just the clock. + assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0))) + } + + @Test + fun `timesIn tracks each zone's own DST rather than a fixed offset`() { + // In January both are on standard time: 08:00 EST is 14:00 CET — the + // same six hours as July, but only because both shifted. Late March, + // when the US has sprung forward and Europe hasn't, the gap is five. + val march = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)), + timezone = "America/New_York", + ) + assertThat(march.timesIn(berlin)!!.first) + .isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0))) + } + + @Test + fun `timesIn returns null when there is nothing to disambiguate`() { + val base = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + ) + // Unpinned: the form's times already are the local times. + assertThat(base.timesIn(berlin)).isNull() + // Pinned to the target itself: same thing. + assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull() + // All-day: date-anchored, so there's no zone conversion to show. + assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin)) + .isNull() + // Unparseable: can't convert, mustn't throw. + assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull() + } + @Test fun `toEditForm turns the exclusive all-day end into the last covered day`() { // 11th..13th = UTC midnights of the 11th and the (exclusive) 14th. From ca01f6e72945764a6eece8f4b099136054a7067a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 16:01:08 +0200 Subject: [PATCH 05/11] fix(detail): keep the zone card's hierarchy matching the When card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The When card reads date-large, time-small-beneath. The zone card led with the original time at titleMedium and dropped the zone label to bodyMedium, inverting that — which made the foreign time the loudest thing on the screen and pulled attention off the local time the reader actually acts on. Put the label back on top and the original time small beneath it, so both cards read the same way and the original stays available without competing. The label already names the zone, so the range drops its "in New York" tail (and the string with it). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/detail/EventDetailScreen.kt | 32 +++++++++---------- app/src/main/res/values/strings.xml | 3 -- 2 files changed, 15 insertions(+), 20 deletions(-) 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 da5ccb8..61fad9f 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 @@ -453,16 +453,20 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi } // Time zone — only when the event is timed and pinned to a zone other - // than the device's, so cross-zone events read unambiguously. The card - // above already answers "when is this for me"; this one answers "when - // was it set", which the zone's name alone never did — an 8 AM New York - // call showing as 2 PM here should still say 8 AM somewhere. + // than the device's, so cross-zone events read unambiguously. It answers + // "when was it set", which the zone's name alone never did: an 8 AM New + // York call showing as 2 PM here should still say 8 AM somewhere. + // + // 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. foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> Spacer(Modifier.height(gap)) DetailCard( icon = Icons.Default.Public, iconContentDescription = stringResource(R.string.event_detail_timezone), ) { + Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) val originalZone = detail.eventTimezone?.let { tz -> runCatching { TimeZone.of(tz) }.getOrNull() } @@ -473,21 +477,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi // the primary instead, so fall back to it rather than // dropping the original time entirely. val (primary, secondary) = formatWhen(instance, originalZone, locale) - Text( - text = stringResource( - R.string.event_detail_timezone_original, - secondary ?: primary, - originalZone.id.substringAfterLast('/').replace('_', ' '), - ), - style = MaterialTheme.typography.titleMedium, - ) Spacer(Modifier.height(2.dp)) + Text( + // The label above already names the zone, so the range + // needs no "in New York" tail to be unambiguous here. + text = secondary ?: primary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - Text( - text = tzLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16cfe3d..eb03dd9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,9 +111,6 @@ the same event expressed where the user actually is. %1$s is a time range, e.g. "2:00 PM – 3:00 PM". --> %1$s your time - - %1$s in %2$s Color From 01ac61185bf1c3e4995fe9a05b843549e05f5c4f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 10:48:21 +0200 Subject: [PATCH 06/11] fix(timezone): show the IANA id + abbreviation, not the long localized name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Central European Time (Europe/Berlin)" is too wide — it made the zone field wrap and stretch. Lead with the id ("Europe/Berlin") and follow it with the abbreviation instead: "CEST · GMT+02:00" in the picker and edit card, "CET · 8:00 AM – 9:00 AM" on the detail card (abbreviation + the event's own-zone time). The abbreviation is resolved DST-aware at the same instant as the offset (CET vs CEST) via "zzz"; zones with no named abbreviation fall back to a "GMT+05:30" form, and zoneDescriptor drops the separate offset in that case so it isn't stated twice. The long localized name is kept on the option for search only — typing "pacific" still works — but no longer shown. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 61 ++++++++++------ .../calendula/ui/common/TimeZonePicker.kt | 10 +-- .../calendula/ui/detail/EventDetailScreen.kt | 71 ++++++++++--------- .../calendula/ui/edit/EventEditScreen.kt | 7 +- .../calendula/domain/TimeZoneCatalogTest.kt | 39 ++++++++++ 5 files changed, 124 insertions(+), 64 deletions(-) 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 ca08fc0..b67af5d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -3,20 +3,31 @@ package de.jeanlucmakiola.calendula.domain import java.text.Normalizer import java.time.Instant import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale /** - * One selectable zone, resolved for display: [id] is the IANA id we store in - * `EVENT_TIMEZONE`, [displayName] its localized name, and [offsetMinutes] its - * offset *at a given instant* — zones shift with DST, so an offset is only - * meaningful next to the moment it was resolved for. + * One selectable zone, resolved for display at a given instant. [id] is the IANA + * id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]); + * [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback); + * [offsetMinutes] its offset. Both the abbreviation and the offset shift with + * DST, so they're only meaningful next to the moment they were resolved for. + * + * [displayName] is the long localized name ("Central European Time"). It's kept + * for search only — matching on it lets someone type "pacific" — and is *not* + * displayed: spelled out next to the id it made the field too wide to fit. */ data class TimeZoneOption( val id: String, val displayName: String, + val shortName: String, val offsetMinutes: Int, ) { + /** The id as the primary label, underscores undone ("America/New York"). */ + val label: String get() = id.replace('_', ' ') + /** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */ val city: String get() = id.substringAfterLast('/').replace('_', ' ') @@ -24,10 +35,20 @@ data class TimeZoneOption( val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") } +private fun optionFor(zone: ZoneId, locale: Locale, at: Instant) = TimeZoneOption( + id = zone.id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + // "zzz" resolves the DST-correct abbreviation at [at] (CET vs CEST); zones + // with no named abbreviation fall back to a "GMT+05:30" form, which is + // exactly what we'd want to show them as anyway. + shortName = ZonedDateTime.ofInstant(at, zone) + .format(DateTimeFormatter.ofPattern("zzz", locale)), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, +) + /** - * Every zone the JVM knows, localized and resolved at [at]. This is ~600 - * entries, so build it once and filter the result rather than rebuilding per - * keystroke. + * 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. * * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are * dropped: they're aliases the tz database keeps for compatibility, they'd @@ -40,14 +61,7 @@ fun timeZoneOptions( ): List = ZoneId.getAvailableZoneIds() .asSequence() .filter { it.contains('/') && !it.startsWith("SystemV/") } - .map { id -> - val zone = ZoneId.of(id) - TimeZoneOption( - id = id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, - ) - } + .map { optionFor(ZoneId.of(it), locale, at) } .sortedWith(compareBy({ it.region }, { it.city })) .toList() @@ -62,11 +76,18 @@ fun timeZoneOptionOf( at: Instant = Instant.now(), ): TimeZoneOption? { val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null - return TimeZoneOption( - id = id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, - ) + return optionFor(zone, locale, at) +} + +/** + * The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". When the + * abbreviation is itself offset-shaped ("UTC", "GMT+05:30") the offset would + * only repeat it, so the abbreviation stands alone. + */ +fun zoneDescriptor(option: TimeZoneOption): String { + val abbrev = option.shortName + val offsetShaped = abbrev == "UTC" || abbrev.startsWith("GMT") + return if (offsetShaped) abbrev else "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" } /** 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 4e1579d..ed74451 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 @@ -35,8 +35,8 @@ 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.formatGmtOffset import de.jeanlucmakiola.calendula.domain.timeZoneOptions +import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField @@ -240,8 +240,8 @@ private fun ZoneRow( onClick: () -> Unit, ) { GroupedRow( - title = zone.city, - summary = "${zone.displayName} · ${formatGmtOffset(zone.offsetMinutes)}", + title = zone.label, + summary = zoneDescriptor(zone), position = position, selected = selected, trailing = if (selected) { @@ -253,8 +253,8 @@ private fun ZoneRow( ) } -/** "Central European Time · GMT+02:00" for [zoneId], or the bare id if unknown. */ +/** "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 { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + ?.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 61fad9f..83c7032 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 @@ -93,6 +93,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.Reminder +import de.jeanlucmakiola.calendula.domain.TimeZoneOption +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf +import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -108,7 +111,6 @@ import kotlinx.datetime.TimeZone import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -460,32 +462,37 @@ 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. - foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> + val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) { + foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale) + } + foreignZone?.let { zoneOption -> Spacer(Modifier.height(gap)) DetailCard( icon = Icons.Default.Public, iconContentDescription = stringResource(R.string.event_detail_timezone), ) { - Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) - val originalZone = detail.eventTimezone?.let { tz -> - runCatching { TimeZone.of(tz) }.getOrNull() - } - if (originalZone != null) { - // formatWhen puts the time range in the secondary half only - // for a same-day event; one spanning midnight — which a - // cross-zone event easily does — carries the whole span in - // the primary instead, so fall back to it rather than - // dropping the original time entirely. - val (primary, secondary) = formatWhen(instance, originalZone, locale) - Spacer(Modifier.height(2.dp)) - Text( - // The label above already names the zone, so the range - // needs no "in New York" tail to be unambiguous here. - text = secondary ?: primary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // The id is the primary label ("Europe/Berlin"); the long + // localized name spelled next to it made the field too wide. + Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium) + // Beneath, small: the abbreviation plus the event's own-zone time, + // e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the + // secondary half only for a same-day event; one spanning midnight + // — which a cross-zone event easily does — carries the whole span + // in the primary instead, so fall back to it. If the zone can't be + // read back at all, drop to just the abbreviation + offset. + val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull() + ?.let { formatWhen(instance, it, locale) } + ?.let { (primary, secondary) -> secondary ?: primary } + Spacer(Modifier.height(2.dp)) + Text( + text = if (originalTime != null) { + "${zoneOption.shortName} · $originalTime" + } else { + zoneDescriptor(zoneOption) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @@ -777,21 +784,15 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when { private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes) /** - * A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"), - * but only when the event is timed and pinned to a zone different from the - * device's. Returns null when there's nothing worth showing. + * [tz] resolved as a [TimeZoneOption], but only when the event is timed and + * 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). */ -private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? { +private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? { if (isAllDay || tz.isNullOrBlank()) return null - val deviceZone = ZoneId.systemDefault().id - if (tz == deviceZone) return null - return try { - val zone = ZoneId.of(tz) - val name = zone.getDisplayName(JavaTextStyle.FULL, locale) - if (name == tz) tz else "$name ($tz)" - } catch (e: Exception) { - tz - } + if (tz == ZoneId.systemDefault().id) return null + return timeZoneOptionOf(tz, locale) } /** 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 97993d2..e2f3a33 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 @@ -112,7 +112,7 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField -import de.jeanlucmakiola.calendula.domain.formatGmtOffset +import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timesIn import de.jeanlucmakiola.calendula.domain.EventFormProblem @@ -745,13 +745,12 @@ private fun EventEditContent( } Column(modifier = Modifier.weight(1f)) { Text( - text = pinned?.city + text = pinned?.label ?: stringResource(R.string.event_edit_timezone_device), style = MaterialTheme.typography.titleMedium, ) Text( - text = pinned - ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + text = pinned?.let { zoneDescriptor(it) } ?: stringResource(R.string.event_edit_timezone_device_summary), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt index fbecfc5..1ef2512 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -30,6 +30,45 @@ class TimeZoneCatalogTest { assertThat(ny.region).isEqualTo("America") } + @Test + fun `label is the id with underscores undone`() { + assertThat(zones.first { it.id == "America/New_York" }.label) + .isEqualTo("America/New York") + assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin") + } + + @Test + fun `resolved abbreviation is compact, not the long name`() { + // Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be + // short and space-free — that's the whole point of showing it instead of + // "Central European Time". + val berlin = zones.first { it.id == "Europe/Berlin" } + assertThat(berlin.shortName).doesNotContain(" ") + assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length) + } + + @Test + fun `descriptor pairs a named abbreviation with the offset`() { + val option = TimeZoneOption( + id = "Europe/Berlin", + displayName = "Central European Time", + shortName = "CET", + offsetMinutes = 60, + ) + assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00") + } + + @Test + fun `descriptor drops the offset when the abbreviation already is one`() { + // Repeating the offset after an offset-shaped abbreviation would just say + // the same thing twice. + fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor( + TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset), + ) + assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC") + assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30") + } + @Test fun `offset is resolved at the given instant, not the current one`() { val berlin = zones.first { it.id == "Europe/Berlin" } From 629896b74d4bf4b52622288efa75272d1b183a5d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:01:25 +0200 Subject: [PATCH 07/11] fix(timezone): resolve the abbreviation via java.util, not java.time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The abbreviation came from java.time's "zzz" formatter, which on Android has no short specific-zone names and silently degrades every zone to a "GMT-4" form — so on device New York showed "GMT-04:00" instead of "EDT". Desktop couldn't catch it: there "zzz" and java.util.TimeZone agree, and that agreement is the whole trap. Resolve through java.util.TimeZone.getDisplayName(inDst, SHORT, locale) instead — ICU-backed, so it returns the real abbreviation on both Android and the JVM. Zones with no named abbreviation still fall back to a GMT form, which zoneDescriptor already collapses to a single token. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) 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 b67af5d..97aa834 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -3,8 +3,6 @@ package de.jeanlucmakiola.calendula.domain import java.text.Normalizer import java.time.Instant import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale @@ -35,16 +33,24 @@ data class TimeZoneOption( val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") } -private fun optionFor(zone: ZoneId, locale: Locale, at: Instant) = TimeZoneOption( - id = zone.id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - // "zzz" resolves the DST-correct abbreviation at [at] (CET vs CEST); zones - // with no named abbreviation fall back to a "GMT+05:30" form, which is - // exactly what we'd want to show them as anyway. - shortName = ZonedDateTime.ofInstant(at, zone) - .format(DateTimeFormatter.ofPattern("zzz", locale)), - offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, -) +private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption { + val offsetSeconds = zone.rules.getOffset(at).totalSeconds + return TimeZoneOption( + id = zone.id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + // The abbreviation ("EDT"/"CEST", DST-correct at [at]). Resolved through + // java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the + // latter has no short specific-zone names and silently degrades every + // zone to a "GMT-4" form (it agrees with java.util only on desktop), so + // "zzz" cost us the real abbreviation on the one platform that ships. + // java.util is ICU-backed and returns the abbreviation on both. Zones + // with genuinely no named abbreviation still fall back to a GMT form, + // which is what we'd show anyway. + shortName = java.util.TimeZone.getTimeZone(zone.id) + .getDisplayName(zone.rules.isDaylightSavings(at), java.util.TimeZone.SHORT, locale), + offsetMinutes = offsetSeconds / 60, + ) +} /** * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it From 734bcae02d0bc053850b1829ef606e3248ddcde7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:15:48 +0200 Subject: [PATCH 08/11] fix(timezone): resolve the abbreviation in the zone's own region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit java.util gave abbreviations only for zones in the display locale's region: an en-DE phone saw "CEST" for Berlin but "GMT-4" for New York, and an en-US phone the reverse — ICU only surfaces the short names commonly used where the reader is. Verified on-device (en-DE): US zones fell back to the offset, EU ones resolved. Ask ICU in the display language but the *zone's* region instead — New York in en-US, Berlin in en-DE — using android.icu's zone→region map. On-device that lights up EDT/PDT/CDT, plus BST, AEST, IST, JST that showed only the offset before. Where a region still has no name (Athens in en-GR) the device locale sometimes does, so fall back to it, then to the offset. The region lookup needs android.icu, which domain/ can't import, so it's injected: timeZoneOptions takes a regionOf lambda (default none, keeping the module pure and JVM-tested), and the UI passes icuTimeZoneRegion. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 90 +++++++++++++------ .../calendula/ui/common/TimeZonePicker.kt | 2 +- .../calendula/ui/common/TimeZoneRegion.kt | 16 ++++ .../calendula/ui/detail/EventDetailScreen.kt | 3 +- .../calendula/ui/edit/EventEditScreen.kt | 3 +- 5 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt 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 97aa834..e688a66 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -33,28 +33,63 @@ data class TimeZoneOption( val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") } -private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption { - val offsetSeconds = zone.rules.getOffset(at).totalSeconds - return TimeZoneOption( - id = zone.id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - // The abbreviation ("EDT"/"CEST", DST-correct at [at]). Resolved through - // java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the - // latter has no short specific-zone names and silently degrades every - // zone to a "GMT-4" form (it agrees with java.util only on desktop), so - // "zzz" cost us the real abbreviation on the one platform that ships. - // java.util is ICU-backed and returns the abbreviation on both. Zones - // with genuinely no named abbreviation still fall back to a GMT form, - // which is what we'd show anyway. - shortName = java.util.TimeZone.getTimeZone(zone.id) - .getDisplayName(zone.rules.isDaylightSavings(at), java.util.TimeZone.SHORT, locale), - offsetMinutes = offsetSeconds / 60, - ) +private fun optionFor( + zone: ZoneId, + locale: Locale, + at: Instant, + regionOf: (String) -> String?, +): TimeZoneOption = TimeZoneOption( + id = zone.id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, +) + +/** + * The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or + * a "GMT+05:30" form when no name exists. + * + * Two things make this fiddlier than it looks. First, it goes through + * java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has + * no short specific-zone names and degrades every zone to a "GMT-4" form (it + * agrees with java.util only on desktop, which is why desktop can't catch it). + * Second, ICU only surfaces the short name *commonly used in the display + * locale's region* — a German-region English phone (en-DE) is shown "CEST" but + * not "EDT", and an en-US phone the reverse. So ask in the display *language* + * but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in + * en-DE) via [regionOf]. That covers almost everything; where a region still + * yields no name (Athens in en-GR) the plain device locale sometimes does, so + * try that next; failing both, the caller shows the offset. + * + * [regionOf] maps an IANA id to an ISO 3166 region and is injected because it + * needs `android.icu`, which this pure-JVM module can't import. The default + * (no region) leaves only the device-locale path — which is all a JVM test has + * anyway, and enough there since desktop ICU isn't region-gated the same way. + */ +private fun resolveAbbreviation( + id: String, + dst: Boolean, + locale: Locale, + regionOf: (String) -> String?, +): String { + fun shortIn(loc: Locale): String = + java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc) + val regional = regionOf(id) + ?.takeIf { it.length == 2 } + ?.let { shortIn(Locale(locale.language, it)) } + if (regional != null && !regional.looksLikeOffset()) return regional + // Either no region, or the region has no name for this zone: the device + // locale is the next-best shot, and the offset the last resort. + return shortIn(locale) } +/** True for the offset-style names ICU returns when a zone has no abbreviation. */ +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. + * once and filter the result rather than rebuilding per keystroke. [regionOf] + * (see [resolveAbbreviation]) supplies the zone's region for the abbreviation. * * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are * dropped: they're aliases the tz database keeps for compatibility, they'd @@ -64,10 +99,11 @@ private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption fun timeZoneOptions( locale: Locale = Locale.getDefault(), at: Instant = Instant.now(), + regionOf: (String) -> String? = { null }, ): List = ZoneId.getAvailableZoneIds() .asSequence() .filter { it.contains('/') && !it.startsWith("SystemV/") } - .map { optionFor(ZoneId.of(it), locale, at) } + .map { optionFor(ZoneId.of(it), locale, at, regionOf) } .sortedWith(compareBy({ it.region }, { it.city })) .toList() @@ -80,20 +116,24 @@ fun timeZoneOptionOf( id: String, locale: Locale = Locale.getDefault(), at: Instant = Instant.now(), + regionOf: (String) -> String? = { null }, ): TimeZoneOption? { val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null - return optionFor(zone, locale, at) + return optionFor(zone, locale, at, regionOf) } /** - * The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". When the - * abbreviation is itself offset-shaped ("UTC", "GMT+05:30") the offset would - * only repeat it, so the abbreviation stands alone. + * The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads + * fine alone; any other offset-shaped name is normalised to our own GMT form so + * the offset isn't stated twice in ICU's spelling and ours. */ fun zoneDescriptor(option: TimeZoneOption): String { val abbrev = option.shortName - val offsetShaped = abbrev == "UTC" || abbrev.startsWith("GMT") - return if (offsetShaped) abbrev else "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" + return when { + abbrev == "UTC" -> "UTC" + abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes) + else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" + } } /** 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 ed74451..fc5d472 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 @@ -67,7 +67,7 @@ fun TimeZonePickerDialog( // ~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) } + val allZones = remember(locale) { 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 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt new file mode 100644 index 0000000..d8e5769 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt @@ -0,0 +1,16 @@ +package de.jeanlucmakiola.calendula.ui.common + +import android.icu.util.TimeZone as IcuTimeZone + +/** + * The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or + * null when ICU has none or maps it to the multi-country "001" ("Etc/UTC"). + * + * This is the `android.icu`-backed bridge the pure-JVM + * [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so + * it can ask ICU for a zone's abbreviation in that zone's own region — the only + * region for which ICU will surface it (see `resolveAbbreviation`). Lives here, + * not in `domain/`, precisely because it needs an Android import. + */ +fun icuTimeZoneRegion(id: String): String? = + runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 } 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 83c7032..639f7fd 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 @@ -98,6 +98,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.OptionCard @@ -792,7 +793,7 @@ private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? { if (isAllDay || tz.isNullOrBlank()) return null if (tz == ZoneId.systemDefault().id) return null - return timeZoneOptionOf(tz, locale) + return timeZoneOptionOf(tz, locale, 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 e2f3a33..3f5d920 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 @@ -131,6 +131,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog +import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.DialogAmountField @@ -741,7 +742,7 @@ private fun EventEditContent( modifier = Modifier.fillMaxWidth(), ) { val pinned = remember(form.timezone, locale) { - form.timezone?.let { timeZoneOptionOf(it, locale) } + form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) } } Column(modifier = Modifier.weight(1f)) { Text( From c3ba8ccf649d689d5616eb7365f4ecedc039cffc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:19:53 +0200 Subject: [PATCH 09/11] test: fix CalendarRepositorySmokeTest against the current constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalendarRepositoryImpl gained a SettingsPrefs parameter, but this instrumented smoke test still called the three-arg constructor — so the whole androidTest source set failed to compile. Pass a SettingsPrefs built on the same DataStore. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarRepositorySmokeTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt index fd76ebc..d6eea3f 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt @@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest { val store: DataStore = PreferenceDataStoreFactory.create( produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") }, ) - return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO) + return CalendarRepositoryImpl( + dataSource, + CalendarPrefs(store), + SettingsPrefs(store), + Dispatchers.IO, + ) } @Test From 4ea68adf8b60b5702d6556b66b50a23ebae148ed Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:25:22 +0200 Subject: [PATCH 10/11] feat(timezone): let the picker search by abbreviation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search matched city, id, and the long localized name but not the abbreviation, so typing "CEST" found nothing. Match it too: an exact abbreviation hit ranks just under a city prefix, so typing an abbreviation gathers every zone that shows it (all the CEST zones at once). It matches the region-resolved abbreviation — i.e. exactly what the row displays. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 16 +++++++++++----- .../calendula/domain/TimeZoneCatalogTest.kt | 13 +++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) 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 e688a66..c107099 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -144,7 +144,10 @@ fun zoneDescriptor(option: TimeZoneOption): String { * Ranking puts a city that *starts with* the query above one that merely * contains it — typing "col" should reach Colombo before Turks_and_Caicos — * and the id is matched ahead of the localized name so a user who knows the - * IANA id gets it first. + * IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a + * city prefix, so typing an abbreviation surfaces every zone that shows it + * (all the CEST zones together); the abbreviation is the one resolved for the + * display region, i.e. what the row actually shows. */ fun filterTimeZones(options: List, query: String): List { val needle = query.normalizeForSearch() @@ -154,12 +157,15 @@ fun filterTimeZones(options: List, query: String): List 0 - name.startsWith(needle) -> 1 - city.contains(needle) -> 2 - id.contains(needle) -> 3 - name.contains(needle) -> 4 + abbrev == needle -> 1 + name.startsWith(needle) -> 2 + abbrev.startsWith(needle) -> 3 + city.contains(needle) -> 4 + id.contains(needle) -> 5 + name.contains(needle) -> 6 else -> return@mapNotNull null } rank to option diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt index 1ef2512..56f6e8b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -105,6 +105,19 @@ class TimeZoneCatalogTest { assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin") } + @Test + fun `query matches the abbreviation, case-insensitively`() { + // "cest" isn't a city, id, or substring of "Central European Summer + // Time", so the only way these match is via the abbreviation. + val hits = filter("cest") + assertThat(hits).isNotEmpty() + assertThat(hits.map { it.id }).contains("Europe/Berlin") + // Every hit genuinely carries that abbreviation — nothing bled in. + assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue() + // Case doesn't matter. + assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id }) + } + @Test fun `a city starting with the query outranks one merely containing it`() { val ids = filter("york").map { it.id } From 7b7b859fee5b6da403fb9ff8ee5aede415a65030 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:47:13 +0200 Subject: [PATCH 11/11] docs(changelog): note per-event time zones (#31) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ .../metadata/android/en-US/changelogs/21600.txt | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b615505..34b5dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.16.0] — 2026-07-17 +### Added +- Give an event its own time zone. A new **Time zone** field (under "more + fields" in the event form) pins an event to a specific zone, so a call set for + 8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps + tracking that zone across daylight-saving changes instead of drifting an hour. + The form edits the event in its own zone and shows the local equivalent under + the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local + time first and note the original beneath it, so both are always clear. Pick a + zone from a full-screen picker with your device zone and recent choices on top, + searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation + ("CEST") to gather every matching zone at once. All-day events stay + date-anchored and carry no zone, as before ([#31]). + ### Changed - Dates in the Month, Week and Day title bars now follow your language and region instead of one hardcoded layout. Every date was rendered in a fixed @@ -1006,6 +1019,7 @@ automatically, with zero telemetry and no internet permission. [#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#21]: https://codeberg.org/jlmakiola/calendula/issues/21 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30 +[#31]: https://codeberg.org/jlmakiola/calendula/issues/31 [#32]: https://codeberg.org/jlmakiola/calendula/issues/32 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt index 4916a79..711f0e3 100644 --- a/fastlane/metadata/android/en-US/changelogs/21600.txt +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -1,3 +1,13 @@ +### Added +- Give an event its own time zone. A new Time zone field (under "more fields" in + the event form) pins an event to a specific zone, so a call set for 8:00 AM in + New York stays 8:00 AM in New York wherever you open it, and keeps tracking + that zone across daylight-saving changes. The form shows the local equivalent + under the times, and the event's details keep your local time first with the + original noted beneath. Pick a zone from a searchable full-screen picker — by + city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored + and carry no zone ([#31]). + ### Changed - Dates in the Month, Week and Day title bars now follow your language and region instead of one hardcoded layout. Every date was rendered in a fixed