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