fix(timezone): show the IANA id + abbreviation, not the long localized name
"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) <noreply@anthropic.com>
This commit is contained in:
@@ -3,20 +3,31 @@ package de.jeanlucmakiola.calendula.domain
|
|||||||
import java.text.Normalizer
|
import java.text.Normalizer
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.TextStyle
|
import java.time.format.TextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One selectable zone, resolved for display: [id] is the IANA id we store in
|
* One selectable zone, resolved for display at a given instant. [id] is the IANA
|
||||||
* `EVENT_TIMEZONE`, [displayName] its localized name, and [offsetMinutes] its
|
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
|
||||||
* offset *at a given instant* — zones shift with DST, so an offset is only
|
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
|
||||||
* meaningful next to the moment it was resolved for.
|
* [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(
|
data class TimeZoneOption(
|
||||||
val id: String,
|
val id: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
val offsetMinutes: Int,
|
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. */
|
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||||
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||||
|
|
||||||
@@ -24,10 +35,20 @@ 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(
|
||||||
|
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
|
* Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it
|
||||||
* entries, so build it once and filter the result rather than rebuilding per
|
* once and filter the result rather than rebuilding per keystroke.
|
||||||
* keystroke.
|
|
||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -40,14 +61,7 @@ fun timeZoneOptions(
|
|||||||
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||||
.map { id ->
|
.map { optionFor(ZoneId.of(it), locale, at) }
|
||||||
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 }))
|
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
@@ -62,11 +76,18 @@ fun timeZoneOptionOf(
|
|||||||
at: Instant = Instant.now(),
|
at: Instant = Instant.now(),
|
||||||
): TimeZoneOption? {
|
): TimeZoneOption? {
|
||||||
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||||
return TimeZoneOption(
|
return optionFor(zone, locale, at)
|
||||||
id = id,
|
}
|
||||||
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
|
||||||
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
/**
|
||||||
)
|
* 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)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
||||||
import de.jeanlucmakiola.calendula.domain.formatGmtOffset
|
|
||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
@@ -240,8 +240,8 @@ private fun ZoneRow(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = zone.city,
|
title = zone.label,
|
||||||
summary = "${zone.displayName} · ${formatGmtOffset(zone.offsetMinutes)}",
|
summary = zoneDescriptor(zone),
|
||||||
position = position,
|
position = position,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
trailing = if (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<TimeZoneOption>): String =
|
private fun zoneSummary(zoneId: String, allZones: List<TimeZoneOption>): String =
|
||||||
allZones.firstOrNull { it.id == zoneId }
|
allZones.firstOrNull { it.id == zoneId }
|
||||||
?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" }
|
?.let { zoneDescriptor(it) }
|
||||||
?: zoneId
|
?: zoneId
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
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.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -108,7 +111,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.Instant
|
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,
|
// 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
|
// 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.
|
// 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))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Public,
|
icon = Icons.Default.Public,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||||
) {
|
) {
|
||||||
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
// The id is the primary label ("Europe/Berlin"); the long
|
||||||
val originalZone = detail.eventTimezone?.let { tz ->
|
// localized name spelled next to it made the field too wide.
|
||||||
runCatching { TimeZone.of(tz) }.getOrNull()
|
Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
|
||||||
}
|
// Beneath, small: the abbreviation plus the event's own-zone time,
|
||||||
if (originalZone != null) {
|
// e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the
|
||||||
// formatWhen puts the time range in the secondary half only
|
// secondary half only for a same-day event; one spanning midnight
|
||||||
// for a same-day event; one spanning midnight — which a
|
// — which a cross-zone event easily does — carries the whole span
|
||||||
// cross-zone event easily does — carries the whole span in
|
// in the primary instead, so fall back to it. If the zone can't be
|
||||||
// the primary instead, so fall back to it rather than
|
// read back at all, drop to just the abbreviation + offset.
|
||||||
// dropping the original time entirely.
|
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
||||||
val (primary, secondary) = formatWhen(instance, originalZone, locale)
|
?.let { formatWhen(instance, it, locale) }
|
||||||
Spacer(Modifier.height(2.dp))
|
?.let { (primary, secondary) -> secondary ?: primary }
|
||||||
Text(
|
Spacer(Modifier.height(2.dp))
|
||||||
// The label above already names the zone, so the range
|
Text(
|
||||||
// needs no "in New York" tail to be unambiguous here.
|
text = if (originalTime != null) {
|
||||||
text = secondary ?: primary,
|
"${zoneOption.shortName} · $originalTime"
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
} else {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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)
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||||
* but only when the event is timed and pinned to a zone different from the
|
* pinned to a zone different from the device's — the cases where showing it
|
||||||
* device's. Returns null when there's nothing worth showing.
|
* 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
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
val deviceZone = ZoneId.systemDefault().id
|
if (tz == ZoneId.systemDefault().id) return null
|
||||||
if (tz == deviceZone) return null
|
return timeZoneOptionOf(tz, locale)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
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.timeZoneOptionOf
|
||||||
import de.jeanlucmakiola.calendula.domain.timesIn
|
import de.jeanlucmakiola.calendula.domain.timesIn
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
@@ -745,13 +745,12 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = pinned?.city
|
text = pinned?.label
|
||||||
?: stringResource(R.string.event_edit_timezone_device),
|
?: stringResource(R.string.event_edit_timezone_device),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = pinned
|
text = pinned?.let { zoneDescriptor(it) }
|
||||||
?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" }
|
|
||||||
?: stringResource(R.string.event_edit_timezone_device_summary),
|
?: stringResource(R.string.event_edit_timezone_device_summary),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -30,6 +30,45 @@ class TimeZoneCatalogTest {
|
|||||||
assertThat(ny.region).isEqualTo("America")
|
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
|
@Test
|
||||||
fun `offset is resolved at the given instant, not the current one`() {
|
fun `offset is resolved at the given instant, not the current one`() {
|
||||||
val berlin = zones.first { it.id == "Europe/Berlin" }
|
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user