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 = "")
|
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption {
|
private fun optionFor(
|
||||||
val offsetSeconds = zone.rules.getOffset(at).totalSeconds
|
zone: ZoneId,
|
||||||
return TimeZoneOption(
|
locale: Locale,
|
||||||
id = zone.id,
|
at: Instant,
|
||||||
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
regionOf: (String) -> String?,
|
||||||
// The abbreviation ("EDT"/"CEST", DST-correct at [at]). Resolved through
|
): TimeZoneOption = TimeZoneOption(
|
||||||
// java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the
|
id = zone.id,
|
||||||
// latter has no short specific-zone names and silently degrades every
|
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||||
// zone to a "GMT-4" form (it agrees with java.util only on desktop), so
|
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
|
||||||
// "zzz" cost us the real abbreviation on the one platform that ships.
|
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||||
// 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)
|
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
|
||||||
.getDisplayName(zone.rules.isDaylightSavings(at), java.util.TimeZone.SHORT, locale),
|
* a "GMT+05:30" form when no name exists.
|
||||||
offsetMinutes = offsetSeconds / 60,
|
*
|
||||||
)
|
* 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
|
* 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
|
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||||
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
* 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(
|
fun timeZoneOptions(
|
||||||
locale: Locale = Locale.getDefault(),
|
locale: Locale = Locale.getDefault(),
|
||||||
at: Instant = Instant.now(),
|
at: Instant = Instant.now(),
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
.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 }))
|
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
@@ -80,20 +116,24 @@ fun timeZoneOptionOf(
|
|||||||
id: String,
|
id: String,
|
||||||
locale: Locale = Locale.getDefault(),
|
locale: Locale = Locale.getDefault(),
|
||||||
at: Instant = Instant.now(),
|
at: Instant = Instant.now(),
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
): TimeZoneOption? {
|
): TimeZoneOption? {
|
||||||
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
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
|
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
|
||||||
* abbreviation is itself offset-shaped ("UTC", "GMT+05:30") the offset would
|
* fine alone; any other offset-shaped name is normalised to our own GMT form so
|
||||||
* only repeat it, so the abbreviation stands alone.
|
* the offset isn't stated twice in ICU's spelling and ours.
|
||||||
*/
|
*/
|
||||||
fun zoneDescriptor(option: TimeZoneOption): String {
|
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||||
val abbrev = option.shortName
|
val abbrev = option.shortName
|
||||||
val offsetShaped = abbrev == "UTC" || abbrev.startsWith("GMT")
|
return when {
|
||||||
return if (offsetShaped) abbrev else "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
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
|
// ~600 zones, each resolving a localized name and a DST-aware offset: build
|
||||||
// once per open, then filter the result per keystroke.
|
// once per open, then filter the result per keystroke.
|
||||||
val locale = currentLocale()
|
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 matches = remember(allZones, query) { filterTimeZones(allZones, query) }
|
||||||
val recentZones = remember(allZones, recents) {
|
val recentZones = remember(allZones, recents) {
|
||||||
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
|
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.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
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.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
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? {
|
private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? {
|
||||||
if (isAllDay || tz.isNullOrBlank()) return null
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
if (tz == ZoneId.systemDefault().id) 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]. */
|
/** 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.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
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.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
@@ -741,7 +742,7 @@ private fun EventEditContent(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
val pinned = remember(form.timezone, locale) {
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user