diff --git a/CHANGELOG.md b/CHANGELOG.md index b615505..34b5dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.16.0] — 2026-07-17 +### Added +- Give an event its own time zone. A new **Time zone** field (under "more + fields" in the event form) pins an event to a specific zone, so a call set for + 8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps + tracking that zone across daylight-saving changes instead of drifting an hour. + The form edits the event in its own zone and shows the local equivalent under + the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local + time first and note the original beneath it, so both are always clear. Pick a + zone from a full-screen picker with your device zone and recent choices on top, + searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation + ("CEST") to gather every matching zone at once. All-day events stay + date-anchored and carry no zone, as before ([#31]). + ### Changed - Dates in the Month, Week and Day title bars now follow your language and region instead of one hardcoded layout. Every date was rendered in a fixed @@ -1006,6 +1019,7 @@ automatically, with zero telemetry and no internet permission. [#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#21]: https://codeberg.org/jlmakiola/calendula/issues/21 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30 +[#31]: https://codeberg.org/jlmakiola/calendula/issues/31 [#32]: https://codeberg.org/jlmakiola/calendula/issues/32 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt index fd76ebc..d6eea3f 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt @@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest { val store: DataStore = PreferenceDataStoreFactory.create( produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") }, ) - return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO) + return CalendarRepositoryImpl( + dataSource, + CalendarPrefs(store), + SettingsPrefs(store), + Dispatchers.IO, + ) } @Test diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 9758587..4053588 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -20,7 +20,11 @@ internal data class EventWriteTimes( /** * All-day events live at UTC midnights with an exclusive DTEND (the * CalendarContract convention — a one-day event ends at the next midnight); - * timed events resolve their wall-clock values in [zone]. + * timed events resolve their wall-clock values in the form's own + * [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin + * one. Passing the device zone is therefore still correct for an unpinned form — + * but it no longer overrides a pinned event's zone, which is what used to + * silently re-anchor a foreign-zone event to the device on any time edit. */ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) { EventWriteTimes( @@ -31,10 +35,11 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa timezone = "UTC", ) } else { + val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone EventWriteTimes( - dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), - dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), - timezone = zone.id, + dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), + dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), + timezone = writeZone.id, ) } @@ -134,10 +139,14 @@ internal fun buildEventUpdateValues( putAll(eventColorColumns(updated.colorKey, updated.color)) } + // A zone change counts as a time change even when the wall-clock is + // untouched: the same 09:00 in another zone is a different instant, so + // DTSTART has to move with it. val timesChanged = updated.start != original.start || updated.end != original.end || updated.isAllDay != original.isAllDay || - updated.rrule != original.rrule + updated.rrule != original.rrule || + updated.timezone != original.timezone if (!timesChanged) return@buildMap val newTimes = updated.toWriteTimes(zone) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index cb4e610..2b0d8ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN +import java.time.ZoneId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -354,6 +355,26 @@ class SettingsPrefs @Inject constructor( } } + /** + * Zones the user picked recently, most recent first — the timezone picker's + * default list, since scrolling ~600 zones to re-find the two you actually + * use is the whole problem. Stored comma-joined (IANA ids never contain a + * comma); unparseable ids are dropped on read, so a zone the tz database + * later retires can't wedge the list. + */ + val recentTimeZones: Flow> = store.data.map { prefs -> + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]) + } + + suspend fun addRecentTimeZone(zoneId: String) { + store.edit { prefs -> + val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])) + .distinct() + .take(MAX_RECENT_TIME_ZONES) + prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",") + } + } + /** * Whether opening the new-event form focuses the title field and raises the * keyboard straight away (issue #10). Default ON — a new event almost always @@ -650,6 +671,13 @@ class SettingsPrefs @Inject constructor( private fun titleTemplateKey(type: SpecialDateType) = stringPreferencesKey("special_dates_title_${type.name}") + private fun parseRecentTimeZones(stored: String?): List = + stored?.split(',').orEmpty() + .map { it.trim() } + .filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess } + .distinct() + .take(MAX_RECENT_TIME_ZONES) + private fun parseFormFields(stored: String?): Set = when (stored) { null -> DEFAULT_FORM_FIELDS else -> stored.split(',') @@ -736,6 +764,10 @@ class SettingsPrefs @Inject constructor( stringPreferencesKey("per_calendar_allday_reminder_override") internal val DEFAULT_FORM_FIELDS = setOf(EventFormField.Location, EventFormField.Description) + internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones") + + /** Enough to cover the zones a user actually recurs to, without a wall of rows. */ + internal const val MAX_RECENT_TIME_ZONES = 5 internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled") internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 5acc612..3d0fde7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant /** * User input for creating an event (and, from v1.3, editing one). Times are - * wall-clock values in the device zone; the data layer translates them to - * provider millis (all-day events normalise to UTC midnights there). + * wall-clock values in [timezone]; the data layer translates them to provider + * millis (all-day events normalise to UTC midnights there). */ data class EventForm( val calendarId: Long?, @@ -18,6 +19,23 @@ data class EventForm( val isAllDay: Boolean = false, val start: LocalDateTime, val end: LocalDateTime, + /** + * The zone [start]/[end] are wall-clock values in, or null to follow the + * device — null is not "no zone", it is "whichever zone the device is in + * when this is saved", which is what an event authored and lived in one + * place wants. The data layer resolves it at write time and always stamps a + * concrete `EVENT_TIMEZONE`. + * + * A non-null value pins the event to a zone regardless of where the device + * is, so it keeps tracking that zone's offset across DST. [toEditForm] only + * sets it when the stored zone differs from the device's, so merely opening + * a local event never reveals the field — and re-opening a pinned one in + * another zone round-trips it rather than silently re-anchoring it. + * + * Always null for all-day events: those are date-anchored, not zone-anchored + * (see [EventFormField.Timezone] and the data layer's UTC-midnight rule). + */ + val timezone: String? = null, val location: String = "", val description: String = "", /** Reminder lead times in minutes before the start, deduplicated. */ @@ -66,11 +84,19 @@ data class EventAttendee( /** * The form's optional sections. Which ones show by default is a user setting; - * the rest unfold behind a "more fields" button. + * the rest unfold behind a "more fields" button. Declaration order is the order + * they're offered in, so a new constant goes where it belongs on the form, not + * at the end. */ enum class EventFormField { Location, Description, + /** + * Pins the event's wall-clock times to a zone. Offered right after the time + * fields it qualifies, and suppressed entirely for all-day events, whose + * dates are deliberately zone-free. + */ + Timezone, Reminders, Recurrence, Availability, @@ -100,8 +126,25 @@ enum class EventFormProblem { * All-day provider times are UTC midnights with an exclusive end; the form * shows the last covered day and keeps placeholder wall-clock times in case * the user switches the event to timed. + * + * A timed event stored in a zone other than [zone] is prefilled *in its own + * zone* and keeps it pinned, so the wall-clock the form shows is the one the + * event means ("the New York 09:00 call") and a later save re-anchors it to the + * same zone rather than the device's. */ 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) { + 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 } + } + val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone val (start, end) = if (instance.isAllDay) { val startDate = Instant.fromEpochMilliseconds(beginMillis) .toLocalDateTime(TimeZone.UTC).date @@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1)) LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) } else { - Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to - Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone) + Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to + Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone) } return EventForm( calendarId = instance.calendarId, @@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): isAllDay = instance.isAllDay, start = start, end = end, + timezone = pinnedZone, location = instance.location.orEmpty(), description = description.orEmpty(), reminders = reminders.map { it.minutes }.distinct().sorted(), @@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon rowEnd = instance.end, ) +/** + * The form's times as they land in [target] — what a pinned event's wall-clock + * actually means where the user is standing. Null when there's nothing to + * disambiguate: an unpinned event (already in [target]), one pinned to [target] + * itself, an all-day event (no zone), or an unparseable pinned zone. + * + * The form edits a pinned event in its own zone, so this is what lets the UI + * show the other side of the pair rather than making the user do the arithmetic. + */ +fun EventForm.timesIn(target: TimeZone): Pair? { + if (isAllDay) return null + val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null + if (pinned.id == target.id) return null + return start.toInstant(pinned).toLocalDateTime(target) to + end.toInstant(pinned).toLocalDateTime(target) +} + /** * The optional sections that hold a value in [form] — when editing, these * must be visible regardless of the user's default-fields setting, or the @@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon fun EventForm.populatedFields(): Set = buildSet { if (location.isNotBlank()) add(EventFormField.Location) if (description.isNotBlank()) add(EventFormField.Description) + if (timezone != null) add(EventFormField.Timezone) if (reminders.isNotEmpty()) add(EventFormField.Reminders) if (rrule != null) add(EventFormField.Recurrence) if (availability != Availability.Busy) add(EventFormField.Availability) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt new file mode 100644 index 0000000..c107099 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -0,0 +1,196 @@ +package de.jeanlucmakiola.calendula.domain + +import java.text.Normalizer +import java.time.Instant +import java.time.ZoneId +import java.time.format.TextStyle +import java.util.Locale + +/** + * One selectable zone, resolved for display at a given instant. [id] is the IANA + * id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]); + * [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback); + * [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( + val id: String, + val displayName: String, + val shortName: String, + 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. */ + val city: String get() = id.substringAfterLast('/').replace('_', ' ') + + /** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */ + val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") +} + +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. [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 + * double up the real zones in the list, and none of them is what a user means + * when they pick a place. + */ +fun timeZoneOptions( + locale: Locale = Locale.getDefault(), + at: Instant = Instant.now(), + regionOf: (String) -> String? = { null }, +): List = ZoneId.getAvailableZoneIds() + .asSequence() + .filter { it.contains('/') && !it.startsWith("SystemV/") } + .map { optionFor(ZoneId.of(it), locale, at, regionOf) } + .sortedWith(compareBy({ it.region }, { it.city })) + .toList() + +/** + * Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz + * database doesn't know it — for labelling one known zone without paying to + * build the whole catalogue. + */ +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, regionOf) +} + +/** + * 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 + return when { + abbrev == "UTC" -> "UTC" + abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes) + else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" + } +} + +/** + * [options] matching [query], best matches first; a blank query returns the list + * unchanged. Matching is accent- and case-insensitive and treats underscores as + * spaces, so "sao paulo" finds "America/Sao_Paulo". + * + * Ranking puts a city that *starts with* the query above one that merely + * contains it — typing "col" should reach Colombo before Turks_and_Caicos — + * and the id is matched ahead of the localized name so a user who knows the + * IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a + * city prefix, so typing an abbreviation surfaces every zone that shows it + * (all the CEST zones together); the abbreviation is the one resolved for the + * display region, i.e. what the row actually shows. + */ +fun filterTimeZones(options: List, query: String): List { + val needle = query.normalizeForSearch() + if (needle.isEmpty()) return options + return options + .mapNotNull { option -> + val city = option.city.normalizeForSearch() + val id = option.id.normalizeForSearch() + val name = option.displayName.normalizeForSearch() + val abbrev = option.shortName.normalizeForSearch() + val rank = when { + city.startsWith(needle) -> 0 + abbrev == needle -> 1 + name.startsWith(needle) -> 2 + abbrev.startsWith(needle) -> 3 + city.contains(needle) -> 4 + id.contains(needle) -> 5 + name.contains(needle) -> 6 + else -> return@mapNotNull null + } + rank to option + } + .sortedWith(compareBy({ it.first }, { it.second.city })) + .map { it.second } +} + +/** + * Lowercased, accent-stripped, underscores and slashes flattened to spaces, so + * a query types the way a place is spoken rather than the way the tz database + * spells it. + */ +private fun String.normalizeForSearch(): String = + Normalizer.normalize(this, Normalizer.Form.NFD) + .replace(Regex("\\p{Mn}+"), "") + .replace('_', ' ') + .replace('/', ' ') + .lowercase(Locale.ROOT) + .trim() + +/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */ +fun formatGmtOffset(offsetMinutes: Int): String { + if (offsetMinutes == 0) return "GMT" + val sign = if (offsetMinutes < 0) '-' else '+' + val abs = kotlin.math.abs(offsetMinutes) + return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt index aa07543..91d84a5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.ui.graphics.vector.ImageVector import de.jeanlucmakiola.calendula.R @@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField fun eventFormFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description + EventFormField.Timezone -> R.string.event_detail_timezone EventFormField.Reminders -> R.string.event_detail_reminders EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability @@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) { fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Location -> Icons.Default.Place EventFormField.Description -> Icons.AutoMirrored.Filled.Notes + EventFormField.Timezone -> Icons.Default.Public EventFormField.Reminders -> Icons.Default.Notifications EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Availability -> Icons.Default.EventAvailable diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt new file mode 100644 index 0000000..fc5d472 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -0,0 +1,260 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.TimeZoneOption +import de.jeanlucmakiola.calendula.domain.filterTimeZones +import de.jeanlucmakiola.calendula.domain.timeZoneOptions +import de.jeanlucmakiola.calendula.domain.zoneDescriptor +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.locale.currentLocale + +/** + * Full-screen zone picker: a query field over every zone the JVM knows, with the + * device zone and recently-picked ones pinned on top so the common case needs no + * typing. [selected] is the pinned zone id, or null when the event follows the + * device — picking the device row reports null back, which is what keeps an + * unpinned event unpinned rather than freezing it to today's zone. + * + * Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this + * drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far + * past what an eagerly composed column can carry. + */ +@Composable +fun TimeZonePickerDialog( + selected: String?, + deviceZoneId: String, + recents: List, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + var query by rememberSaveable { mutableStateOf("") } + // ~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, regionOf = ::icuTimeZoneRegion) } + val matches = remember(allZones, query) { filterTimeZones(allZones, query) } + val recentZones = remember(allZones, recents) { + recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } } + } + val searching = query.isNotBlank() + + fun choose(zoneId: String?) { + onSelect(zoneId) + onDismiss() + } + + FullScreenPicker( + title = stringResource(R.string.event_detail_timezone), + onDismiss = onDismiss, + scrollable = false, + ) { + SearchField( + query = query, + onQueryChange = { query = it }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { + if (!searching) { + // The device row is the "unpinned" choice, so it reads as a mode + // rather than as a zone among zones — it stays out of the big + // list and never carries an offset. + item(key = "device") { + GroupedRow( + title = stringResource(R.string.event_edit_timezone_device), + summary = zoneSummary(deviceZoneId, allZones), + position = Position.Alone, + selected = selected == null, + leading = { Icon(Icons.Default.Public, contentDescription = null) }, + trailing = if (selected == null) { + { SelectedCheck() } + } else { + null + }, + onClick = { choose(null) }, + ) + } + if (recentZones.isNotEmpty()) { + item(key = "recent_header") { + SectionHeader(stringResource(R.string.event_edit_timezone_recent)) + } + itemsIndexed( + items = recentZones, + key = { _, zone -> "recent_${zone.id}" }, + ) { index, zone -> + ZoneRow( + zone = zone, + position = positionOf(index, recentZones.size), + selected = zone.id == selected, + onClick = { choose(zone.id) }, + ) + } + } + item(key = "all_header") { + SectionHeader(stringResource(R.string.event_edit_timezone_all)) + } + } + + if (searching && matches.isEmpty()) { + item(key = "empty") { + Text( + text = stringResource(R.string.event_edit_timezone_none, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 48.dp), + ) + } + } + + itemsIndexed( + items = matches, + key = { _, zone -> "zone_${zone.id}" }, + ) { index, zone -> + ZoneRow( + zone = zone, + position = positionOf(index, matches.size), + selected = zone.id == selected, + onClick = { choose(zone.id) }, + ) + } + } + } +} + +/** + * The query box: the family's borderless [InlineTextField] over a tonal surface, + * so it reads like the rest of the app's inputs rather than a boxed Material + * field. The clear button rides inside the surface (unlike the search screen, + * which has a top bar to put it in) because the picker's bar is the title. + */ +@Composable +private fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val keyboard = LocalSoftwareKeyboardController.current + // surfaceContainerHighest — the picker sits on surfaceContainerHigh, so + // anything lower vanishes into it. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(28.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + InlineTextField( + value = query, + onValueChange = onQueryChange, + placeholder = stringResource(R.string.event_edit_timezone_search), + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Search, + onImeAction = { keyboard?.hide() }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 14.dp), + ) + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +/** A small primary-coloured group label, matching the settings screens. */ +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun SelectedCheck() { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun ZoneRow( + zone: TimeZoneOption, + position: Position, + selected: Boolean, + onClick: () -> Unit, +) { + GroupedRow( + title = zone.label, + summary = zoneDescriptor(zone), + position = position, + selected = selected, + trailing = if (selected) { + { SelectedCheck() } + } else { + null + }, + onClick = onClick, + ) +} + +/** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */ +private fun zoneSummary(zoneId: String, allZones: List): String = + allZones.firstOrNull { it.id == zoneId } + ?.let { zoneDescriptor(it) } + ?: zoneId diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt new file mode 100644 index 0000000..d8e5769 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 3d148fe..639f7fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -93,8 +93,12 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.RecurringWriteScope 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.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 @@ -108,7 +112,6 @@ import kotlinx.datetime.TimeZone import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -453,14 +456,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi } // Time zone — only when the event is timed and pinned to a zone other - // than the device's, so cross-zone events read unambiguously. - foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> + // than the device's, so cross-zone events read unambiguously. It answers + // "when was it set", which the zone's name alone never did: an 8 AM New + // York call showing as 2 PM here should still say 8 AM somewhere. + // + // 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 + // acts on, so the original must stay quieter than it, not compete. + val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) { + foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale) + } + foreignZone?.let { zoneOption -> Spacer(Modifier.height(gap)) DetailCard( icon = Icons.Default.Public, iconContentDescription = stringResource(R.string.event_detail_timezone), ) { - Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) + // The id is the primary label ("Europe/Berlin"); the long + // localized name spelled next to it made the field too wide. + Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium) + // Beneath, small: the abbreviation plus the event's own-zone time, + // e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the + // secondary half only for a same-day event; one spanning midnight + // — 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() + ?.let { formatWhen(instance, it, locale) } + ?.let { (primary, secondary) -> secondary ?: primary } + Spacer(Modifier.height(2.dp)) + Text( + text = if (originalTime != null) { + "${zoneOption.shortName} · $originalTime" + } else { + zoneDescriptor(zoneOption) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @@ -752,21 +785,15 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when { private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes) /** - * A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"), - * but only when the event is timed and pinned to a zone different from the - * device's. Returns null when there's nothing worth showing. + * [tz] resolved as a [TimeZoneOption], but only when the event is timed and + * pinned to a zone different from the device's — the cases where showing it + * 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 - val deviceZone = ZoneId.systemDefault().id - if (tz == deviceZone) return null - 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 - } + if (tz == ZoneId.systemDefault().id) return null + return timeZoneOptionOf(tz, locale, regionOf = ::icuTimeZoneRegion) } /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 11ec532..3f5d920 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune @@ -111,6 +112,9 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.zoneDescriptor +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf +import de.jeanlucmakiola.calendula.domain.timesIn import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceFreq @@ -126,6 +130,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette 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 @@ -160,6 +166,7 @@ import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle @@ -506,6 +513,7 @@ private fun EventEditContent( var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } var showColorPicker by rememberSaveable { mutableStateOf(false) } + var showTimezonePicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } // Pick a guest from contacts: a one-shot system picker returning the chosen @@ -670,6 +678,22 @@ private fun EventEditContent( color = MaterialTheme.colorScheme.error, ) } + // The rows above edit a pinned event in *its own* zone, which is the + // time it was set at — but that says nothing about when it lands for + // whoever is reading it. Spell the local equivalent out rather than + // leaving the user to do the offset arithmetic. Absent (null) unless + // the event is pinned somewhere other than here. + form.timesIn(TimeZone.currentSystemDefault())?.let { (localStart, localEnd) -> + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource( + R.string.event_edit_timezone_local_time, + formatTimeRange(localStart, localEnd, locale), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Spacer(Modifier.height(gap)) @@ -706,6 +730,42 @@ private fun EventEditContent( // Optional sections: which ones render by default is a setting; the // rest unfold behind the "more fields" button below. + OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Default.Public, + iconContentDescription = null, + onClick = { showTimezonePicker = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + val pinned = remember(form.timezone, locale) { + form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = pinned?.label + ?: stringResource(R.string.event_edit_timezone_device), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = pinned?.let { zoneDescriptor(it) } + ?: stringResource(R.string.event_edit_timezone_device_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + OptionalFormSection(visible = EventFormField.Location in state.visibleFields) { Spacer(Modifier.height(gap)) EditCard( @@ -1081,6 +1141,16 @@ private fun EventEditContent( ) } + if (showTimezonePicker) { + TimeZonePickerDialog( + selected = form.timezone, + deviceZoneId = ZoneId.systemDefault().id, + recents = state.recentTimeZones, + onSelect = viewModel::setTimezone, + onDismiss = { showTimezonePicker = false }, + ) + } + if (showColorPicker) { ColorPickerDialog( palette = state.colorPalette, @@ -1933,6 +2003,21 @@ private fun EditCard( } } +/** + * "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one + * time when both land on the same minute (an instant event shouldn't read as a + * range against itself). Dates are deliberately absent — the rows above carry + * them. + */ +@Composable +private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String { + val use24Hour = LocalUse24HourFormat.current + val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) } + val from = timeFormat.format(start.time.toJavaLocalTime()) + val to = timeFormat.format(end.time.toJavaLocalTime()) + return if (from == to) from else "$from – $to" +} + /** * Borderless text input used inside the cards (and as the headline title). * Thin wrapper over the shared [InlineTextField] so the form and the rest of diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 934b792..6a68d04 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -27,6 +27,8 @@ data class EventEditUiState( * neither list. */ val hiddenFields: List = emptyList(), + /** Recently picked zones, most recent first — the zone picker's shortlist. */ + val recentTimeZones: List = emptyList(), /** True while editing an existing event (the calendar is then fixed). */ val isEditing: Boolean = false, /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 8308538..b339f39 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -219,7 +220,8 @@ class EventEditViewModel @Inject constructor( ).flowOn(io), colorPalette, allCalendars, - ) { local, external, palette, allCalendars -> + settingsPrefs.recentTimeZones.flowOn(io), + ) { local, external, palette, allCalendars, recentTimeZones -> val form = local.form ?: return@combine null val resolvedId = form.calendarId ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } @@ -235,14 +237,19 @@ class EventEditViewModel @Inject constructor( val pickerCalendars = if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar else external.writable - val visibleFields = external.defaultFields + local.revealed + // An all-day event is date-anchored, so a zone is meaningless on it — + // the field is withheld from both lists rather than shown as a no-op. + val offerableFields = EventFormField.entries.toSet() - + if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet() + val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields EventEditUiState( form = resolved, calendars = pickerCalendars, problems = if (local.showProblems) resolved.problems() else emptySet(), saveState = local.saveState, visibleFields = visibleFields, - hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), + hiddenFields = (offerableFields - visibleFields).sorted(), + recentTimeZones = recentTimeZones, isEditing = local.editTarget != null, isManaged = isManaged, autofocusTitle = external.autofocusTitle, @@ -453,12 +460,28 @@ class EventEditViewModel @Inject constructor( fun setLocation(value: String) = update { it.copy(location = value) } fun setDescription(value: String) = update { it.copy(description = value) } fun setAllDay(value: Boolean) { - update { it.copy(isAllDay = value) } + // Going all-day drops any pinned zone: the times become bare dates that + // the provider stores UTC-anchored, so a zone could only misrepresent + // them. Coming back out of all-day leaves the event on the device zone, + // which is the same thing a fresh event gets. + update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) } // The default reminder differs for all-day vs timed; re-apply the // type-appropriate default unless the user has hand-edited it (guarded). applyDefaultReminder() } + /** + * Pin the event to [zoneId], or pass null to follow the device. The zone + * rides along as a recent so the picker can offer it without a search next + * time — only real picks are remembered, not the device fallback. + */ + fun setTimezone(zoneId: String?) { + update { it.copy(timezone = zoneId) } + if (zoneId != null) { + viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } } + } + } + /** * Switching calendars drops any chosen colour: a palette key is * account-scoped, and a raw colour may be invalid on the new calendar. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c22fb3..43e3bdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,19 @@ Availability Visibility + + Device time zone + Follows wherever you are + Search time zones + Recent + All time zones + No time zone matches “%1$s” + Use device zone + + %1$s your time + Color Calendar color diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 972b9d9..b606a15 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -19,7 +19,14 @@ class EventWriteMapperTest { isAllDay: Boolean = false, start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)), - ): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end) + timezone: String? = null, + ): EventForm = EventForm( + calendarId = 1L, + isAllDay = isAllDay, + start = start, + end = end, + timezone = timezone, + ) @Test fun `timed event resolves wall clock in the given zone`() { @@ -30,6 +37,26 @@ class EventWriteMapperTest { assertThat(times.timezone).isEqualTo("Europe/Berlin") } + @Test + fun `pinned zone wins over the device zone`() { + val times = form(timezone = "America/New_York").toWriteTimes(berlin) + // 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give. + assertThat(times.timezone).isEqualTo("America/New_York") + assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L) + } + + @Test + fun `an unparseable pinned zone falls back to the device zone`() { + val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("Europe/Berlin") + } + + @Test + fun `all-day ignores a pinned zone and stays UTC`() { + val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("UTC") + } + @Test fun `all-day event lives at UTC midnights with exclusive end`() { val times = form(isAllDay = true).toWriteTimes(berlin) @@ -105,6 +132,42 @@ class EventWriteMapperTest { assertThat(update(original, original.copy())).isEmpty() } + @Test + fun `editing the time of a pinned event keeps its zone`() { + // The regression this guards: the update used to stamp the device zone + // over the event's own, silently un-anchoring a foreign-zone event so it + // stopped tracking that zone across DST. + val original = form(timezone = "America/New_York") + val values = update(original, original.copy(title = "Standup", start = original.start)) + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE) + + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)), + ) + assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE]) + .isEqualTo("America/New_York") + } + + @Test + fun `changing only the zone still moves the event`() { + // Same wall-clock, different zone: the instant moves, so DTSTART must be + // rewritten even though start/end compare equal. + val original = form() + val values = update(original, original.copy(timezone = "America/New_York")) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York") + // 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later. + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L) + } + + @Test + fun `unpinning back to the device zone rewrites the times`() { + val original = form(timezone = "America/New_York") + val values = update(original, original.copy(timezone = null)) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) + } + @Test fun `text-only edit writes just the changed columns`() { val original = form() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index fc975c6..b2fb058 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -117,6 +117,7 @@ class EventFormTest { attendees: List = emptyList(), eventColor: Int? = null, eventColorKey: String? = null, + eventTimezone: String? = null, ): EventDetail = EventDetail( instance = EventInstance( instanceId = 1L, @@ -138,6 +139,7 @@ class EventFormTest { accessLevel = accessLevel, eventColor = eventColor, eventColorKey = eventColorKey, + eventTimezone = eventTimezone, ) @Test @@ -157,6 +159,117 @@ class EventFormTest { assertThat(prefilled.description).isEqualTo("Body") } + @Test + fun `toEditForm leaves an event in the device zone unpinned`() { + val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + // Same zone as the device: pinning it would only make the picker appear + // on every ordinary event. + assertThat(prefilled.timezone).isNull() + assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone) + } + + @Test + fun `toEditForm pins a foreign zone and shows the times in it`() { + val prefilled = detail(eventTimezone = "America/New_York").toEditForm( + beginMillis = 1_781_164_800_000L, // 08:00Z + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + assertThat(prefilled.timezone).isEqualTo("America/New_York") + // 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the + // event's own wall-clock, which is what a later save re-anchors to. + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0))) + assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone) + } + + @Test + fun `toEditForm never pins a zone on an all-day event`() { + // All-day rows carry a nominal "UTC" that is an anchor, not a location. + val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm( + beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L, + endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L, + zone = berlin, + ) + assertThat(prefilled.timezone).isNull() + } + + @Test + fun `toEditForm ignores an unparseable stored zone`() { + val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + // A malformed sync row must not be honoured, nor fail the open. + assertThat(prefilled.timezone).isNull() + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) + } + + @Test + fun `timesIn converts a pinned event into the target zone`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + timezone = "America/New_York", + ) + val (start, end) = form.timesIn(berlin)!! + // 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2). + assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0))) + assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0))) + } + + @Test + fun `timesIn crosses the date line when the offset pushes past midnight`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)), + timezone = "America/New_York", + ) + val (start, _) = form.timesIn(berlin)!! + // 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move + // with it, not just the clock. + assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0))) + } + + @Test + fun `timesIn tracks each zone's own DST rather than a fixed offset`() { + // In January both are on standard time: 08:00 EST is 14:00 CET — the + // same six hours as July, but only because both shifted. Late March, + // when the US has sprung forward and Europe hasn't, the gap is five. + val march = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)), + timezone = "America/New_York", + ) + assertThat(march.timesIn(berlin)!!.first) + .isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0))) + } + + @Test + fun `timesIn returns null when there is nothing to disambiguate`() { + val base = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + ) + // Unpinned: the form's times already are the local times. + assertThat(base.timesIn(berlin)).isNull() + // Pinned to the target itself: same thing. + assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull() + // All-day: date-anchored, so there's no zone conversion to show. + assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin)) + .isNull() + // Unparseable: can't convert, mustn't throw. + assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull() + } + @Test fun `toEditForm turns the exclusive all-day end into the last covered day`() { // 11th..13th = UTC midnights of the 11th and the (exclusive) 14th. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt new file mode 100644 index 0000000..56f6e8b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -0,0 +1,159 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.Locale + +class TimeZoneCatalogTest { + + // A fixed instant so DST-dependent offsets can't drift with the wall clock: + // 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT. + private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z") + private val zones = timeZoneOptions(Locale.ENGLISH, summer) + + private fun filter(query: String) = filterTimeZones(zones, query) + + @Test + fun `catalogue holds the real zones and drops the legacy aliases`() { + assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York") + // Bare aliases the tz database keeps for compatibility would double up + // the real zones in the picker. + assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC") + assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue() + } + + @Test + fun `option exposes city and region split from the id`() { + val ny = zones.first { it.id == "America/New_York" } + assertThat(ny.city).isEqualTo("New York") + 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 + fun `offset is resolved at the given instant, not the current one`() { + val berlin = zones.first { it.id == "Europe/Berlin" } + // CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST. + assertThat(berlin.offsetMinutes).isEqualTo(120) + + val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z")) + assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60) + } + + @Test + fun `blank query returns everything unchanged`() { + assertThat(filter("")).isEqualTo(zones) + assertThat(filter(" ")).isEqualTo(zones) + } + + @Test + fun `query matches the city, ignoring case and underscores`() { + assertThat(filter("new york").map { it.id }).contains("America/New_York") + assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York") + assertThat(filter("new_york").map { it.id }).contains("America/New_York") + } + + @Test + fun `query matches accented cities typed plainly`() { + // Sao_Paulo has no accent in the id, but its localized name does — the + // point is that a user typing plain ASCII still finds it. + assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo") + assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich") + } + + @Test + fun `query matches the full IANA id`() { + assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin") + } + + @Test + fun `query matches the abbreviation, case-insensitively`() { + // "cest" isn't a city, id, or substring of "Central European Summer + // Time", so the only way these match is via the abbreviation. + val hits = filter("cest") + assertThat(hits).isNotEmpty() + assertThat(hits.map { it.id }).contains("Europe/Berlin") + // Every hit genuinely carries that abbreviation — nothing bled in. + assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue() + // Case doesn't matter. + assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id }) + } + + @Test + fun `a city starting with the query outranks one merely containing it`() { + val ids = filter("york").map { it.id } + // "New York" contains "york"; nothing starts with it, so it should still + // surface rather than being buried. + assertThat(ids).contains("America/New_York") + + // "col" starts Colombo but only appears mid-string elsewhere. + val col = filter("col").map { it.id } + assertThat(col.first()).isEqualTo("Asia/Colombo") + } + + @Test + fun `no match yields an empty list rather than everything`() { + assertThat(filter("zzzznotazone")).isEmpty() + } + + @Test + fun `single zone resolves the same way the catalogue does`() { + val fromCatalogue = zones.first { it.id == "Europe/Berlin" } + assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer)) + .isEqualTo(fromCatalogue) + } + + @Test + fun `an unknown zone id resolves to null`() { + assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull() + } + + @Test + fun `gmt offsets format with a sign and padding`() { + assertThat(formatGmtOffset(0)).isEqualTo("GMT") + assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00") + assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00") + // India is +05:30 — a whole-hour assumption would render this wrong. + assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30") + assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21d1f42..a8c3600 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,6 +99,30 @@ on-device — see plan 03): UTC, so zones ahead of UTC can't leak an extra occurrence. - All-day events are normalised to UTC midnights with an exclusive end. +### Event time zones + +`EventForm.timezone` is the zone its wall-clock times mean, and **null means +"the device zone at save time"** — not "no zone". The data layer resolves it in +`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary +event behaves exactly as it did before the field existed. + +- A non-null value **pins** the event: it keeps tracking that zone's offset + across DST no matter where the device is. `toEditForm` only pins when the + stored zone differs from the device's, so the optional Time-zone field stays + hidden on ordinary events and reveals itself (via `populatedFields`) on + foreign-zone ones. +- A pinned event is prefilled **in its own zone**, so the form shows the + wall-clock the event means rather than the device's rendering of it. +- A zone change counts as a **time change** even with the wall-clock untouched + (same 09:00 elsewhere is a different instant), so `buildEventUpdateValues` + includes it in `timesChanged` and rewrites `DTSTART`. +- **All-day events never carry a zone.** They're date-anchored — the UTC + midnights above are an anchor, not a location — so the field is withheld from + the form entirely and `toWriteTimes` forces `"UTC"` regardless. + +Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and +`AllDayReminderEncoding`'s offset (see its KDoc). + ## Save conflicts No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt index 4916a79..711f0e3 100644 --- a/fastlane/metadata/android/en-US/changelogs/21600.txt +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -1,3 +1,13 @@ +### Added +- Give an event its own time zone. A new Time zone field (under "more fields" in + the event form) pins an event to a specific zone, so a call set for 8:00 AM in + New York stays 8:00 AM in New York wherever you open it, and keeps tracking + that zone across daylight-saving changes. The form shows the local equivalent + under the times, and the event's details keep your local time first with the + original noted beneath. Pick a zone from a searchable full-screen picker — by + city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored + and carry no zone ([#31]). + ### Changed - Dates in the Month, Week and Day title bars now follow your language and region instead of one hardcoded layout. Every date was rendered in a fixed diff --git a/floret-kit b/floret-kit index a50878a..df8bdba 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit a50878a7ac638e725984e9cf447d693c711f52c4 +Subproject commit df8bdbaf73380a354b7d2ee28e08875e155c89cd