fix(timezone): resolve the abbreviation in the zone's own region

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 11:15:48 +02:00
parent 629896b74d
commit 734bcae02d
5 changed files with 86 additions and 28 deletions

View File

@@ -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<TimeZoneOption> = 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)}"
}
}
/**

View File

@@ -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 } }

View File

@@ -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 }

View File

@@ -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]. */

View File

@@ -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(