Compare commits
5 Commits
release/v2
...
feat/timez
| Author | SHA1 | Date | |
|---|---|---|---|
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff |
@@ -20,7 +20,11 @@ internal data class EventWriteTimes(
|
|||||||
/**
|
/**
|
||||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
* 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) {
|
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
@@ -31,10 +35,11 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
timezone = zone.id,
|
timezone = writeZone.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +139,14 @@ internal fun buildEventUpdateValues(
|
|||||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
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 ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
updated.isAllDay != original.isAllDay ||
|
updated.isAllDay != original.isAllDay ||
|
||||||
updated.rrule != original.rrule
|
updated.rrule != original.rrule ||
|
||||||
|
updated.timezone != original.timezone
|
||||||
if (!timesChanged) return@buildMap
|
if (!timesChanged) return@buildMap
|
||||||
|
|
||||||
val newTimes = updated.toWriteTimes(zone)
|
val newTimes = updated.toWriteTimes(zone)
|
||||||
|
|||||||
@@ -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.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
|
import java.time.ZoneId
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
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<List<String>> = 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
|
* 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
|
* 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) =
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
stringPreferencesKey("special_dates_title_${type.name}")
|
stringPreferencesKey("special_dates_title_${type.name}")
|
||||||
|
|
||||||
|
private fun parseRecentTimeZones(stored: String?): List<String> =
|
||||||
|
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<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -736,6 +764,10 @@ class SettingsPrefs @Inject constructor(
|
|||||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
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_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
* 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
|
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||||
* provider millis (all-day events normalise to UTC midnights there).
|
* millis (all-day events normalise to UTC midnights there).
|
||||||
*/
|
*/
|
||||||
data class EventForm(
|
data class EventForm(
|
||||||
val calendarId: Long?,
|
val calendarId: Long?,
|
||||||
@@ -18,6 +19,23 @@ data class EventForm(
|
|||||||
val isAllDay: Boolean = false,
|
val isAllDay: Boolean = false,
|
||||||
val start: LocalDateTime,
|
val start: LocalDateTime,
|
||||||
val end: 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 location: String = "",
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
/** 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 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 {
|
enum class EventFormField {
|
||||||
Location,
|
Location,
|
||||||
Description,
|
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,
|
Reminders,
|
||||||
Recurrence,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
|||||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
* 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
|
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||||
* the user switches the event to timed.
|
* 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 {
|
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 (start, end) = if (instance.isAllDay) {
|
||||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
.toLocalDateTime(TimeZone.UTC).date
|
.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))
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
} else {
|
} else {
|
||||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||||
}
|
}
|
||||||
return EventForm(
|
return EventForm(
|
||||||
calendarId = instance.calendarId,
|
calendarId = instance.calendarId,
|
||||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
isAllDay = instance.isAllDay,
|
isAllDay = instance.isAllDay,
|
||||||
start = start,
|
start = start,
|
||||||
end = end,
|
end = end,
|
||||||
|
timezone = pinnedZone,
|
||||||
location = instance.location.orEmpty(),
|
location = instance.location.orEmpty(),
|
||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
rowEnd = instance.end,
|
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<LocalDateTime, LocalDateTime>? {
|
||||||
|
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
|
* 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
|
* 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<EventFormField> = buildSet {
|
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||||
if (location.isNotBlank()) add(EventFormField.Location)
|
if (location.isNotBlank()) add(EventFormField.Location)
|
||||||
if (description.isNotBlank()) add(EventFormField.Description)
|
if (description.isNotBlank()) add(EventFormField.Description)
|
||||||
|
if (timezone != null) add(EventFormField.Timezone)
|
||||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||||
if (rrule != null) add(EventFormField.Recurrence)
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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: [id] is the IANA id we store in
|
||||||
|
* `EVENT_TIMEZONE`, [displayName] its localized name, and [offsetMinutes] its
|
||||||
|
* offset *at a given instant* — zones shift with DST, so an offset is only
|
||||||
|
* meaningful next to the moment it was resolved for.
|
||||||
|
*/
|
||||||
|
data class TimeZoneOption(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val offsetMinutes: Int,
|
||||||
|
) {
|
||||||
|
/** 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 = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every zone the JVM knows, localized and resolved at [at]. This is ~600
|
||||||
|
* entries, so build it once and filter the result rather than rebuilding per
|
||||||
|
* keystroke.
|
||||||
|
*
|
||||||
|
* 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(),
|
||||||
|
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||||
|
.map { id ->
|
||||||
|
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 }))
|
||||||
|
.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(),
|
||||||
|
): TimeZoneOption? {
|
||||||
|
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||||
|
return TimeZoneOption(
|
||||||
|
id = id,
|
||||||
|
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||||
|
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [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.
|
||||||
|
*/
|
||||||
|
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||||
|
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 rank = when {
|
||||||
|
city.startsWith(needle) -> 0
|
||||||
|
name.startsWith(needle) -> 1
|
||||||
|
city.contains(needle) -> 2
|
||||||
|
id.contains(needle) -> 3
|
||||||
|
name.contains(needle) -> 4
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
|
|||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
|||||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
|
EventFormField.Timezone -> R.string.event_detail_timezone
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
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) {
|
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
EventFormField.Location -> Icons.Default.Place
|
EventFormField.Location -> Icons.Default.Place
|
||||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||||
|
EventFormField.Timezone -> Icons.Default.Public
|
||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
EventFormField.Reminders -> Icons.Default.Notifications
|
||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
|
|||||||
@@ -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.formatGmtOffset
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
|
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<String>,
|
||||||
|
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) }
|
||||||
|
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.city,
|
||||||
|
summary = "${zone.displayName} · ${formatGmtOffset(zone.offsetMinutes)}",
|
||||||
|
position = position,
|
||||||
|
selected = selected,
|
||||||
|
trailing = if (selected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Central European Time · GMT+02:00" for [zoneId], or the bare id if unknown. */
|
||||||
|
private fun zoneSummary(zoneId: String, allZones: List<TimeZoneOption>): String =
|
||||||
|
allZones.firstOrNull { it.id == zoneId }
|
||||||
|
?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" }
|
||||||
|
?: zoneId
|
||||||
@@ -453,7 +453,13 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Time zone — only when the event is timed and pinned to a zone other
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
// than the device's, so cross-zone events read unambiguously.
|
// 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.
|
||||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
@@ -461,6 +467,25 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||||
) {
|
) {
|
||||||
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||||
|
val originalZone = detail.eventTimezone?.let { tz ->
|
||||||
|
runCatching { TimeZone.of(tz) }.getOrNull()
|
||||||
|
}
|
||||||
|
if (originalZone != null) {
|
||||||
|
// formatWhen puts the time 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 rather than
|
||||||
|
// dropping the original time entirely.
|
||||||
|
val (primary, secondary) = formatWhen(instance, originalZone, locale)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
// The label above already names the zone, so the range
|
||||||
|
// needs no "in New York" tail to be unambiguous here.
|
||||||
|
text = secondary ?: primary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Person
|
|||||||
import androidx.compose.material.icons.filled.PersonAdd
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.Place
|
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.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Tune
|
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.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.timeZoneOptionOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timesIn
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
@@ -126,6 +130,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
@@ -160,6 +165,7 @@ import kotlinx.datetime.toJavaLocalDate
|
|||||||
import kotlinx.datetime.toKotlinDayOfWeek
|
import kotlinx.datetime.toKotlinDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalTime
|
import kotlinx.datetime.toJavaLocalTime
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
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.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -506,6 +512,7 @@ private fun EventEditContent(
|
|||||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||||
@@ -670,6 +677,22 @@ private fun EventEditContent(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
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))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -706,6 +729,43 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
// Optional sections: which ones render by default is a setting; the
|
// Optional sections: which ones render by default is a setting; the
|
||||||
// rest unfold behind the "more fields" button below.
|
// 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) }
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = pinned?.city
|
||||||
|
?: stringResource(R.string.event_edit_timezone_device),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pinned
|
||||||
|
?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" }
|
||||||
|
?: 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) {
|
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
EditCard(
|
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) {
|
if (showColorPicker) {
|
||||||
ColorPickerDialog(
|
ColorPickerDialog(
|
||||||
palette = state.colorPalette,
|
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).
|
* 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
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ data class EventEditUiState(
|
|||||||
* neither list.
|
* neither list.
|
||||||
*/
|
*/
|
||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
|
/** Recently picked zones, most recent first — the zone picker's shortlist. */
|
||||||
|
val recentTimeZones: List<String> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -219,7 +220,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
allCalendars,
|
allCalendars,
|
||||||
) { local, external, palette, allCalendars ->
|
settingsPrefs.recentTimeZones.flowOn(io),
|
||||||
|
) { local, external, palette, allCalendars, recentTimeZones ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
@@ -235,14 +237,19 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val pickerCalendars =
|
val pickerCalendars =
|
||||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
else external.writable
|
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(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = pickerCalendars,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||||
|
recentTimeZones = recentTimeZones,
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
isManaged = isManaged,
|
isManaged = isManaged,
|
||||||
autofocusTitle = external.autofocusTitle,
|
autofocusTitle = external.autofocusTitle,
|
||||||
@@ -453,12 +460,28 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
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
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
applyDefaultReminder()
|
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
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
|
|||||||
@@ -99,6 +99,19 @@
|
|||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</string>
|
<string name="event_edit_visibility">Visibility</string>
|
||||||
|
|
||||||
|
<!-- Event form — time zone -->
|
||||||
|
<string name="event_edit_timezone_device">Device time zone</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">Follows wherever you are</string>
|
||||||
|
<string name="event_edit_timezone_search">Search time zones</string>
|
||||||
|
<string name="event_edit_timezone_recent">Recent</string>
|
||||||
|
<string name="event_edit_timezone_all">All time zones</string>
|
||||||
|
<string name="event_edit_timezone_none">No time zone matches “%1$s”</string>
|
||||||
|
<string name="event_edit_timezone_clear">Use device zone</string>
|
||||||
|
<!-- Shown under the time fields when the event is pinned to another zone:
|
||||||
|
the same event expressed where the user actually is. %1$s is a time
|
||||||
|
range, e.g. "2:00 PM – 3:00 PM". -->
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s your time</string>
|
||||||
|
|
||||||
<!-- Event form — per-event color -->
|
<!-- Event form — per-event color -->
|
||||||
<string name="event_edit_color">Color</string>
|
<string name="event_edit_color">Color</string>
|
||||||
<string name="event_edit_color_default">Calendar color</string>
|
<string name="event_edit_color_default">Calendar color</string>
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ class EventWriteMapperTest {
|
|||||||
isAllDay: Boolean = false,
|
isAllDay: Boolean = false,
|
||||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
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
|
@Test
|
||||||
fun `timed event resolves wall clock in the given zone`() {
|
fun `timed event resolves wall clock in the given zone`() {
|
||||||
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
|
|||||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
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
|
@Test
|
||||||
fun `all-day event lives at UTC midnights with exclusive end`() {
|
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||||
val times = form(isAllDay = true).toWriteTimes(berlin)
|
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||||
@@ -105,6 +132,42 @@ class EventWriteMapperTest {
|
|||||||
assertThat(update(original, original.copy())).isEmpty()
|
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
|
@Test
|
||||||
fun `text-only edit writes just the changed columns`() {
|
fun `text-only edit writes just the changed columns`() {
|
||||||
val original = form()
|
val original = form()
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class EventFormTest {
|
|||||||
attendees: List<Attendee> = emptyList(),
|
attendees: List<Attendee> = emptyList(),
|
||||||
eventColor: Int? = null,
|
eventColor: Int? = null,
|
||||||
eventColorKey: String? = null,
|
eventColorKey: String? = null,
|
||||||
|
eventTimezone: String? = null,
|
||||||
): EventDetail = EventDetail(
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 1L,
|
instanceId = 1L,
|
||||||
@@ -138,6 +139,7 @@ class EventFormTest {
|
|||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
eventColor = eventColor,
|
eventColor = eventColor,
|
||||||
eventColorKey = eventColorKey,
|
eventColorKey = eventColorKey,
|
||||||
|
eventTimezone = eventTimezone,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -157,6 +159,117 @@ class EventFormTest {
|
|||||||
assertThat(prefilled.description).isEqualTo("Body")
|
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
|
@Test
|
||||||
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||||
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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 `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 `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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,30 @@ on-device — see plan 03):
|
|||||||
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
||||||
- All-day events are normalised to UTC midnights with an exclusive end.
|
- 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
|
## Save conflicts
|
||||||
|
|
||||||
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
||||||
|
|||||||
Submodule floret-kit updated: a50878a7ac...df8bdbaf73
Reference in New Issue
Block a user