Add zoneOrNull for the repeated TimeZone.of guards

This commit is contained in:
2026-10-16 13:35:34 +02:00
parent bb8c91acc5
commit e7a8aea56c
6 changed files with 64 additions and 11 deletions
@@ -148,16 +148,17 @@ enum class EventFormProblem {
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) {
val pinned = 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 }
?.let { id -> zoneOrNull(id)?.let { id to it } }
}
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
val pinnedZone = pinned?.first
val formZone = pinned?.second ?: zone
val begin = Instant.fromEpochMilliseconds(beginMillis)
val finish = Instant.fromEpochMilliseconds(endMillis)
val (start, end) = if (instance.isAllDay) {
@@ -243,8 +244,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
*/
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
if (isAllDay) return false
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false
return pinned.id != target.id
return resolvedZone(target).id != target.id
}
/**
@@ -17,12 +17,15 @@ import kotlin.time.Instant
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant
/** [id] as a zone, or null when the tz database doesn't know it. */
internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull()
/**
* The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
*/
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
timezone?.let(::zoneOrNull) ?: deviceZone
/**
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.calendula.domain.ics
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */
private val BASIC_DATE_TIME = LocalDateTime.Format {
date(LocalDate.Formats.ISO_BASIC)
char('T')
hour()
minute()
second()
}
/** `yyyyMMdd`. */
internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC)
/** `yyyyMMddTHHmmss`, floating. */
internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME)
/** `yyyyMMddTHHmmssZ`, the instant in UTC. */
internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z"
/**
* The date in the first eight characters of a basic-format value; whatever
* follows (a time, a `Z`) is ignored. Null when those aren't a valid date.
*/
internal fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */
internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = parseBasicDate(s) ?: return null
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -343,7 +344,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
*/
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList()
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
val startZone = zoneOrNull(start.zoneId) ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time
return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
@@ -373,7 +374,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when {
token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
else -> line.params["TZID"]?.let(::zoneOrNull)
?: startZone
}
return ldt.toInstant(zone)
@@ -409,7 +410,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
val tzid = line.params["TZID"]
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } }
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
@@ -68,7 +69,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
}
// Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> {
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
val zone = zoneOrNull(event.zoneId)
if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
@@ -89,6 +89,7 @@ import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -420,7 +421,7 @@ private fun EventDetailContent(
// — 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()
val originalTime = zoneOrNull(zoneOption.id)
?.let { rememberWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp))