Compare commits
9 Commits
main
...
feat/timez
| Author | SHA1 | Date | |
|---|---|---|---|
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff | |||
| 60938af9f0 | |||
| accf0ac142 | |||
| f90badfcd5 | |||
| 249606a358 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.16.0] — 2026-07-17
|
||||||
|
|
||||||
|
### 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
|
||||||
|
German-style order with a trailing dot on the day number, whatever your
|
||||||
|
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||||
|
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||||
|
disagreed about the same date. All four views now share one formatter, and the
|
||||||
|
day/month order, the separators and the ordinal all come from your locale —
|
||||||
|
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||||
|
each correct for where you are ([#60]).
|
||||||
|
- The title bar drops the year while you're in the current one — "July" rather
|
||||||
|
than "July 2026". The year reappears the moment you page out of the current
|
||||||
|
year, which is when it tells you something you didn't already know.
|
||||||
|
- The Week view's title now names the month instead of spelling out the day range.
|
||||||
|
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||||
|
headers right below it, in the widest string in the bar. A week that straddles
|
||||||
|
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||||
|
|
||||||
## [2.15.0] — 2026-07-15
|
## [2.15.0] — 2026-07-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -997,3 +1019,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||||
|
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21500
|
versionCode = 21600
|
||||||
versionName = "2.15.0"
|
versionName = "2.16.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -76,7 +76,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a calendar title (top bar or widget header): [skeleton]'s fields laid
|
||||||
|
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||||
|
* [currentYear].
|
||||||
|
*
|
||||||
|
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||||
|
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||||
|
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||||
|
*
|
||||||
|
* Dropping the year in the current year is the one bit of policy here: the title
|
||||||
|
* sits directly above a grid that already says which year it is, and the year's
|
||||||
|
* *absence* is itself the signal that you're in the current one — it appears the
|
||||||
|
* moment you page out of it, which is when it starts carrying information.
|
||||||
|
*/
|
||||||
|
fun formatCalendarTitle(
|
||||||
|
date: java.time.LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
skeleton: String,
|
||||||
|
): String {
|
||||||
|
val fields = if (date.year == currentYear) skeleton else skeleton + "y"
|
||||||
|
return localizedDateFormatter(locale, fields).format(date)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.core.os.ConfigurationCompat
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
|
||||||
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
|
||||||
* weekday/month name formatting.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun currentLocale(): Locale {
|
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
return remember(configuration) {
|
|
||||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
|
|
||||||
* (via Android's best-pattern matching), so dates read naturally per locale
|
|
||||||
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
|
|
||||||
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
|
|
||||||
*/
|
|
||||||
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
|
|
||||||
DateTimeFormatter.ofPattern(
|
|
||||||
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
|
|
||||||
locale,
|
|
||||||
)
|
|
||||||
@@ -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
|
||||||
@@ -68,6 +68,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -83,7 +84,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
@@ -93,7 +94,9 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -157,6 +160,13 @@ fun DayScreen(
|
|||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drives whether the title carries the year. Falls back to the clock only
|
||||||
|
// while the first load is in flight, when there is no state to read today from.
|
||||||
|
val currentYear = when (val s = state) {
|
||||||
|
is DayUiState.Success -> s.today.year
|
||||||
|
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||||
|
}
|
||||||
|
|
||||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -205,6 +215,7 @@ fun DayScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
@@ -360,16 +371,18 @@ private fun DaySuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DayTopBar(
|
private fun DayTopBar(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatDayTitle(date),
|
text = formatDayTitle(date, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -669,10 +682,10 @@ private fun DayLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate): String {
|
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||||
val locale = Locale.getDefault()
|
formatCalendarTitle(
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
locale = locale,
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
currentYear = currentYear,
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
skeleton = "EEEdMMM",
|
||||||
}
|
)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
@@ -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
|
||||||
@@ -143,7 +148,7 @@ import de.jeanlucmakiola.floret.components.positionOf
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -79,7 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
@@ -130,6 +131,13 @@ fun MonthScreen(
|
|||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drives whether the title carries the year. Falls back to the clock only
|
||||||
|
// while the first load is in flight, when there is no state to read today from.
|
||||||
|
val currentYear = when (val s = state) {
|
||||||
|
is MonthUiState.Success -> s.today.year
|
||||||
|
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||||
|
}
|
||||||
|
|
||||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
@@ -186,6 +194,7 @@ fun MonthScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
month = month,
|
month = month,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
@@ -294,16 +303,18 @@ private fun MonthContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthTopBar(
|
private fun MonthTopBar(
|
||||||
month: YearMonth,
|
month: YearMonth,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatMonthYear(month),
|
text = formatMonthTitle(month, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -732,9 +743,10 @@ private fun MonthGridLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatMonthYear(ym: YearMonth): String {
|
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
|
||||||
val locale = Locale.getDefault()
|
formatCalendarTitle(
|
||||||
val name = java.time.Month.of(ym.month.ordinal + 1)
|
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
||||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
locale = locale,
|
||||||
return "$name ${ym.year}"
|
currentYear = currentYear,
|
||||||
}
|
skeleton = "LLLL",
|
||||||
|
)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
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.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ import de.jeanlucmakiola.floret.components.positionOf
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -93,7 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
@@ -180,6 +181,13 @@ fun WeekScreen(
|
|||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drives whether the title carries the year. Falls back to the clock only
|
||||||
|
// while the first load is in flight, when there is no state to read today from.
|
||||||
|
val currentYear = when (val s = state) {
|
||||||
|
is WeekUiState.Success -> s.today.year
|
||||||
|
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||||
|
}
|
||||||
|
|
||||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -228,6 +236,7 @@ fun WeekScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
@@ -395,16 +404,18 @@ private fun WeekSuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekTopBar(
|
private fun WeekTopBar(
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatWeekRange(weekStart),
|
text = formatWeekTitle(weekStart, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -854,18 +865,21 @@ private fun WeekLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
/**
|
||||||
val locale = Locale.getDefault()
|
* The week's title: just the month [weekStart] falls in.
|
||||||
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
*
|
||||||
val monthName = { d: LocalDate ->
|
* The day numbers are already in the column headers directly below, so spelling
|
||||||
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
|
||||||
}
|
* month of [weekStart] means a week straddling a boundary keeps the outgoing
|
||||||
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
* month until it is fully gone — a week is seven contiguous days, so the earlier
|
||||||
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
* month has a day in it exactly while [weekStart] is still inside it. That also
|
||||||
} else if (weekStart.year == end.year) {
|
* makes the title depend on nothing but [weekStart], so it cannot drift with the
|
||||||
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
* direction you paged in from.
|
||||||
} else {
|
*/
|
||||||
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String =
|
||||||
"${end.day}. ${monthName(end)} ${end.year}"
|
formatCalendarTitle(
|
||||||
}
|
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||||
}
|
locale = locale,
|
||||||
|
currentYear = currentYear,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import de.jeanlucmakiola.calendula.MainActivity
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
@@ -162,7 +163,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
|||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym))
|
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||||
weeks.forEach { week ->
|
weeks.forEach { week ->
|
||||||
@@ -480,9 +481,13 @@ private fun PermissionMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun monthLabel(month: YearMonth): String {
|
// Locale.getDefault() rather than currentLocale(): Glance has no
|
||||||
val locale = Locale.getDefault()
|
// LocalConfiguration, and the widget is re-rendered on a configuration change
|
||||||
val name = java.time.Month.of(month.month.ordinal + 1)
|
// anyway (same convention as AgendaWidget).
|
||||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
||||||
return "$name ${month.year}"
|
formatCalendarTitle(
|
||||||
}
|
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
currentYear = currentYear,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
18
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
18
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
### 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
|
||||||
|
German-style order with a trailing dot on the day number, whatever your
|
||||||
|
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||||
|
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||||
|
disagreed about the same date. All four views now share one formatter, and the
|
||||||
|
day/month order, the separators and the ordinal all come from your locale —
|
||||||
|
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||||
|
each correct for where you are ([#60]).
|
||||||
|
- The title bar drops the year while you're in the current one — "July" rather
|
||||||
|
than "July 2026". The year reappears the moment you page out of the current
|
||||||
|
year, which is when it tells you something you didn't already know.
|
||||||
|
- The Week view's title now names the month instead of spelling out the day range.
|
||||||
|
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||||
|
headers right below it, in the widest string in the bar. A week that straddles
|
||||||
|
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||||
|
|
||||||
Submodule floret-kit updated: 2124227a7f...df8bdbaf73
Reference in New Issue
Block a user