Compare commits

...

9 Commits

Author SHA1 Message Date
ca01f6e729 fix(detail): keep the zone card's hierarchy matching the When card
The When card reads date-large, time-small-beneath. The zone card led
with the original time at titleMedium and dropped the zone label to
bodyMedium, inverting that — which made the foreign time the loudest
thing on the screen and pulled attention off the local time the reader
actually acts on.

Put the label back on top and the original time small beneath it, so both
cards read the same way and the original stays available without
competing. The label already names the zone, so the range drops its "in
New York" tail (and the string with it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:01:08 +02:00
0588609c75 fix(edit): use the family's text input, and show both times when zones differ
Two review fixes.

The zone picker's search box was a raw Material OutlinedTextField — the
only one left in the app, and against the convention DialogControls
states outright ("the family's InlineTextField over a tonal surface, not
Material's outlined field"). Rebuild it on InlineTextField over a tonal
surface, with the clear button inside the surface since the picker's top
bar is the title rather than a search field.

Showing a pinned event only in its own zone answered "what was it set
to?" while dropping "when is it for me?" — the user had to do the offset
arithmetic. Show both whenever they differ:

- the edit form keeps editing the event in its own zone (that's the time
  it was set at) and captions it with the local equivalent;
- the detail screen keeps local times primary and now leads the zone card
  with the original ("8:00 AM – 9:00 AM in New York") instead of naming
  the zone and nothing else.

EventForm.timesIn is pure, so the conversion — including crossing the
date line and each zone's own DST, which don't move together — is a
plain JUnit test rather than something only reviewable on a phone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:54:33 +02:00
ab3df639b9 chore: pin floret-kit to v0.2.1
The time-zone picker needs the scaffold's `scrollable` opt-out, which
landed in 0.2.1. Pins the tag rather than the branch commit the work was
developed against, keeping the tag-pinning convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:37:56 +02:00
fefe1f3652 feat(edit): add a searchable time-zone picker (#31)
Surface the zone as an optional form field, right after the time fields
it qualifies: hidden on ordinary events, revealed automatically when the
event already carries a foreign zone, and withheld entirely while all-day
is on (a date-anchored event has no zone to show).

The picker is a full-screen one per the app's convention, but it can't
reuse OptionPicker: that composes every option eagerly, and ~600 zones
would all compose on open. It drives its own LazyColumn instead, which
needs the kit's new `scrollable = false` — the scaffold's own
verticalScroll would otherwise throw on a nested same-axis scrollable.
The device zone and recently-picked zones pin to the top so the common
case needs no typing; search is accent- and case-insensitive.

Recents persist in DataStore, capped at five, dropping ids the tz
database no longer knows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:32:04 +02:00
7634df3cff feat(domain): let an event pin its own time zone (#31)
Every write sampled ZoneId.systemDefault() and stamped it into
EVENT_TIMEZONE, so the column was real but only ever held the device's
zone: an event synced from elsewhere could be read in its zone, never
authored in one.

Give EventForm a nullable `timezone`, where null keeps meaning "the
device zone at save time" — so every existing call site behaves exactly
as before — and a non-null value pins the event to a zone it then tracks
across DST. toWriteTimes resolves the form's zone ahead of the device's;
toEditForm pins only when the stored zone differs from the device's, and
prefills such an event in its own zone so the form shows the wall-clock
the event actually means.

Two provider-contract bugs fall out of this:

- Editing the time of a foreign-zone event rewrote EVENT_TIMEZONE to the
  device's. The instants stayed right, so nothing looked wrong, but the
  event silently stopped tracking its zone and would drift an hour at the
  next DST boundary. Only the timesChanged gate spared title-only edits.
- A zone change with an untouched wall-clock is still a time change (the
  same 09:00 elsewhere is a different instant), so it now trips
  timesChanged and rewrites DTSTART instead of being dropped.

All-day events keep carrying no zone at all: they're date-anchored, and
the UTC midnights they normalise to are an anchor rather than a location.

TimeZoneCatalog is pure JVM so the search ranking and DST-aware offsets
stay plain JUnit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:31:54 +02:00
60938af9f0 chore: pin floret-kit to v0.2.0
Tracks the tagged kit main rather than the feature branch the merge left it on.
A release branch pointing at an unmerged branch is the failure mode main carried
since 2.15.0 (its pointer lived only on feat/custom-snooze-duration): the release
pipeline builds from the tagged source tree, so a rebased or deleted branch would
break the published release, not just a local checkout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:56:55 +02:00
accf0ac142 release: cut 2.16.0
Locale-aware calendar titles (#60): Month, Week and Day move onto the shared
formatter Agenda already used, the year drops out while you're in the current
one, and the Week title names its month rather than restating the day numbers
printed directly below it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:53:13 +02:00
f90badfcd5 Merge branch 'feat/unify-date-formatting' into release/v2.16.0 2026-07-17 14:51:41 +02:00
249606a358 fix(i18n): unify the calendar titles on one locale-aware formatter (#60)
Month, Week and Day each hand-rolled their title from a German-style template —
"$weekday, ${date.day}. $monthName ${date.year}" — so every language got a
trailing ordinal dot and day-before-month order, including ones that write the
month first. English read "Fri, 17. Jul 2026" where it should read
"Fri, Jul 17, 2026".

396a561 already fixed exactly this, but only for Agenda: it added
localizedDateFormatter and migrated the four agenda files, leaving Month, Week,
Day and MonthWidget on their originals. Day and Agenda therefore disagreed about
the same date. All four now route through formatCalendarTitle, so there is one
place left that decides how a title reads.

Two behaviour changes fall out of the reporter's point that the bar wastes space:

The year is dropped while you are in the current year. The title sits above a
grid that already says which year it is; the year's absence is itself the signal
that you are in the current one, and it reappears when you page out — the moment
it starts carrying information. Since a skeleton is a field list, this is just
appending "y", and the locale still places it.

The Week title names a month instead of a day range. "24. Jun – 31. Jun" restated
the day numbers printed in the column headers directly below, in the widest
string in the bar. Naming weekStart's month keeps a straddling week on the
outgoing month until it is fully gone — a week is seven contiguous days, so the
earlier month has a day in it exactly while weekStart is inside it. No straddle
conditional, and the title depends on nothing but weekStart, so it cannot drift
with the direction you paged in from.

currentLocale/localizedDateFormatter move to floret-kit's core-locale (neither is
calendar-specific); LocaleSupport.kt goes away and its 11 callers repoint.
MonthWidget keeps Locale.getDefault() — Glance has no LocalConfiguration and the
widget re-renders on a configuration change, matching AgendaWidget.

Does not touch the FAB stack the issue opens with: the buttons overlaying content
is intentional and matches Google Calendar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:47:58 +02:00
30 changed files with 1126 additions and 102 deletions

View File

@@ -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/),
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
### Added
@@ -997,3 +1019,4 @@ automatically, with zero telemetry and no internet permission.
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21500
versionName = "2.15.0"
versionCode = 21600
versionName = "2.16.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -20,7 +20,11 @@ internal data class EventWriteTimes(
/**
* All-day events live at UTC midnights with an exclusive DTEND (the
* CalendarContract convention — a one-day event ends at the next midnight);
* timed events resolve their wall-clock values in [zone].
* timed events resolve their wall-clock values in the form's own
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
* one. Passing the device zone is therefore still correct for an unpinned form —
* but it no longer overrides a pinned event's zone, which is what used to
* silently re-anchor a foreign-zone event to the device on any time edit.
*/
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
EventWriteTimes(
@@ -31,10 +35,11 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
timezone = "UTC",
)
} else {
val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone
EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
timezone = zone.id,
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
timezone = writeZone.id,
)
}
@@ -134,10 +139,14 @@ internal fun buildEventUpdateValues(
putAll(eventColorColumns(updated.colorKey, updated.color))
}
// A zone change counts as a time change even when the wall-clock is
// untouched: the same 09:00 in another zone is a different instant, so
// DTSTART has to move with it.
val timesChanged = updated.start != original.start ||
updated.end != original.end ||
updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule
updated.rrule != original.rrule ||
updated.timezone != original.timezone
if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone)

View File

@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import java.time.ZoneId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -354,6 +355,26 @@ class SettingsPrefs @Inject constructor(
}
}
/**
* Zones the user picked recently, most recent first — the timezone picker's
* default list, since scrolling ~600 zones to re-find the two you actually
* use is the whole problem. Stored comma-joined (IANA ids never contain a
* comma); unparseable ids are dropped on read, so a zone the tz database
* later retires can't wedge the list.
*/
val recentTimeZones: Flow<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
* keyboard straight away (issue #10). Default ON — a new event almost always
@@ -650,6 +671,13 @@ class SettingsPrefs @Inject constructor(
private fun titleTemplateKey(type: SpecialDateType) =
stringPreferencesKey("special_dates_title_${type.name}")
private fun parseRecentTimeZones(stored: String?): List<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) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
@@ -736,6 +764,10 @@ class SettingsPrefs @Inject constructor(
stringPreferencesKey("per_calendar_allday_reminder_override")
internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
internal const val MAX_RECENT_TIME_ZONES = 5
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")

View File

@@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/**
* User input for creating an event (and, from v1.3, editing one). Times are
* wall-clock values in the device zone; the data layer translates them to
* provider millis (all-day events normalise to UTC midnights there).
* wall-clock values in [timezone]; the data layer translates them to provider
* millis (all-day events normalise to UTC midnights there).
*/
data class EventForm(
val calendarId: Long?,
@@ -18,6 +19,23 @@ data class EventForm(
val isAllDay: Boolean = false,
val start: LocalDateTime,
val end: LocalDateTime,
/**
* The zone [start]/[end] are wall-clock values in, or null to follow the
* device — null is not "no zone", it is "whichever zone the device is in
* when this is saved", which is what an event authored and lived in one
* place wants. The data layer resolves it at write time and always stamps a
* concrete `EVENT_TIMEZONE`.
*
* A non-null value pins the event to a zone regardless of where the device
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
* sets it when the stored zone differs from the device's, so merely opening
* a local event never reveals the field — and re-opening a pinned one in
* another zone round-trips it rather than silently re-anchoring it.
*
* Always null for all-day events: those are date-anchored, not zone-anchored
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
*/
val timezone: String? = null,
val location: String = "",
val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */
@@ -66,11 +84,19 @@ data class EventAttendee(
/**
* The form's optional sections. Which ones show by default is a user setting;
* the rest unfold behind a "more fields" button.
* the rest unfold behind a "more fields" button. Declaration order is the order
* they're offered in, so a new constant goes where it belongs on the form, not
* at the end.
*/
enum class EventFormField {
Location,
Description,
/**
* Pins the event's wall-clock times to a zone. Offered right after the time
* fields it qualifies, and suppressed entirely for all-day events, whose
* dates are deliberately zone-free.
*/
Timezone,
Reminders,
Recurrence,
Availability,
@@ -100,8 +126,25 @@ enum class EventFormProblem {
* All-day provider times are UTC midnights with an exclusive end; the form
* shows the last covered day and keeps placeholder wall-clock times in case
* the user switches the event to timed.
*
* A timed event stored in a zone other than [zone] is prefilled *in its own
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
* event means ("the New York 09:00 call") and a later save re-anchors it to the
* same zone rather than the device's.
*/
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
// All-day events are date-anchored and carry a nominal "UTC" that is an
// anchor, not a location, so they never pin a zone.
val pinnedZone = if (instance.isAllDay) {
null
} else {
eventTimezone
?.takeIf { it != zone.id }
// An unparseable id (a malformed sync row) can't be honoured or
// shown; fall back to the device zone rather than failing the open.
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
}
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
val (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(TimeZone.UTC).date
@@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
}
return EventForm(
calendarId = instance.calendarId,
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
isAllDay = instance.isAllDay,
start = start,
end = end,
timezone = pinnedZone,
location = instance.location.orEmpty(),
description = description.orEmpty(),
reminders = reminders.map { it.minutes }.distinct().sorted(),
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
rowEnd = instance.end,
)
/**
* The form's times as they land in [target] — what a pinned event's wall-clock
* actually means where the user is standing. Null when there's nothing to
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
* itself, an all-day event (no zone), or an unparseable pinned zone.
*
* The form edits a pinned event in its own zone, so this is what lets the UI
* show the other side of the pair rather than making the user do the arithmetic.
*/
fun EventForm.timesIn(target: TimeZone): Pair<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
* 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 {
if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description)
if (timezone != null) add(EventFormField.Timezone)
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability)

View File

@@ -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)
}

View File

@@ -1,6 +1,6 @@
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.LocalDate
import java.time.YearMonth

View File

@@ -68,7 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
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.floret.components.GroupedRow
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.floret.components.positionOf
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.formatTimeOfDay
import kotlinx.coroutines.launch

View File

@@ -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)
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Timezone -> R.string.event_detail_timezone
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
@@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Location -> Icons.Default.Place
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
EventFormField.Timezone -> Icons.Default.Public
EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable

View File

@@ -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,
)

View File

@@ -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

View File

@@ -68,6 +68,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
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.CalendarFabColumn
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.eventInk
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.LocalShowHourLines
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.launch
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 kotlin.math.roundToInt
@@ -157,6 +160,13 @@ fun DayScreen(
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.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
@@ -205,6 +215,7 @@ fun DayScreen(
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
@@ -360,16 +371,18 @@ private fun DaySuccess(
@Composable
private fun DayTopBar(
date: LocalDate,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatDayTitle(date),
text = formatDayTitle(date, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
)
},
@@ -669,10 +682,10 @@ private fun DayLoading() {
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
return "$weekday, ${date.day}. $monthName ${date.year}"
}
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale,
currentYear = currentYear,
skeleton = "EEEdMMM",
)

View File

@@ -98,7 +98,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
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.timeOfDayFormatter
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
// 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 ->
Spacer(Modifier.height(gap))
DetailCard(
@@ -461,6 +467,25 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
iconContentDescription = stringResource(R.string.event_detail_timezone),
) {
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,
)
}
}
}

View File

@@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tune
@@ -111,6 +112,9 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.formatGmtOffset
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timesIn
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
@@ -126,6 +130,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
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.TimePickerAlert
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.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
@@ -160,6 +165,7 @@ import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toKotlinDayOfWeek
import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
@@ -506,6 +512,7 @@ private fun EventEditContent(
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
// Pick a guest from contacts: a one-shot system picker returning the chosen
@@ -670,6 +677,22 @@ private fun EventEditContent(
color = MaterialTheme.colorScheme.error,
)
}
// The rows above edit a pinned event in *its own* zone, which is the
// time it was set at — but that says nothing about when it lands for
// whoever is reading it. Spell the local equivalent out rather than
// leaving the user to do the offset arithmetic. Absent (null) unless
// the event is pinned somewhere other than here.
form.timesIn(TimeZone.currentSystemDefault())?.let { (localStart, localEnd) ->
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(
R.string.event_edit_timezone_local_time,
formatTimeRange(localStart, localEnd, locale),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(gap))
@@ -706,6 +729,43 @@ private fun EventEditContent(
// Optional sections: which ones render by default is a setting; the
// rest unfold behind the "more fields" button below.
OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
icon = Icons.Default.Public,
iconContentDescription = null,
onClick = { showTimezonePicker = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
val pinned = remember(form.timezone, locale) {
form.timezone?.let { timeZoneOptionOf(it, locale) }
}
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) {
Spacer(Modifier.height(gap))
EditCard(
@@ -1081,6 +1141,16 @@ private fun EventEditContent(
)
}
if (showTimezonePicker) {
TimeZonePickerDialog(
selected = form.timezone,
deviceZoneId = ZoneId.systemDefault().id,
recents = state.recentTimeZones,
onSelect = viewModel::setTimezone,
onDismiss = { showTimezonePicker = false },
)
}
if (showColorPicker) {
ColorPickerDialog(
palette = state.colorPalette,
@@ -1933,6 +2003,21 @@ private fun EditCard(
}
}
/**
* "2:00 PM 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
* time when both land on the same minute (an instant event shouldn't read as a
* range against itself). Dates are deliberately absent — the rows above carry
* them.
*/
@Composable
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
val use24Hour = LocalUse24HourFormat.current
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
val from = timeFormat.format(start.time.toJavaLocalTime())
val to = timeFormat.format(end.time.toJavaLocalTime())
return if (from == to) from else "$from $to"
}
/**
* Borderless text input used inside the cards (and as the headline title).
* Thin wrapper over the shared [InlineTextField] so the form and the rest of

View File

@@ -27,6 +27,8 @@ data class EventEditUiState(
* neither list.
*/
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). */
val isEditing: Boolean = false,
/**

View File

@@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -219,7 +220,8 @@ class EventEditViewModel @Inject constructor(
).flowOn(io),
colorPalette,
allCalendars,
) { local, external, palette, allCalendars ->
settingsPrefs.recentTimeZones.flowOn(io),
) { local, external, palette, allCalendars, recentTimeZones ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
@@ -235,14 +237,19 @@ class EventEditViewModel @Inject constructor(
val pickerCalendars =
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
else external.writable
val visibleFields = external.defaultFields + local.revealed
// An all-day event is date-anchored, so a zone is meaningless on it —
// the field is withheld from both lists rather than shown as a no-op.
val offerableFields = EventFormField.entries.toSet() -
if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet()
val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields
EventEditUiState(
form = resolved,
calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
hiddenFields = (offerableFields - visibleFields).sorted(),
recentTimeZones = recentTimeZones,
isEditing = local.editTarget != null,
isManaged = isManaged,
autofocusTitle = external.autofocusTitle,
@@ -453,12 +460,28 @@ class EventEditViewModel @Inject constructor(
fun setLocation(value: String) = update { it.copy(location = value) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) {
update { it.copy(isAllDay = value) }
// Going all-day drops any pinned zone: the times become bare dates that
// the provider stores UTC-anchored, so a zone could only misrepresent
// them. Coming back out of all-day leaves the event on the device zone,
// which is the same thing a fresh event gets.
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder()
}
/**
* Pin the event to [zoneId], or pass null to follow the device. The zone
* rides along as a recent so the picker can offer it without a search next
* time — only real picks are remembered, not the device fallback.
*/
fun setTimezone(zoneId: String?) {
update { it.copy(timezone = zoneId) }
if (zoneId != null) {
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
}
}
/**
* Switching calendars drops any chosen colour: a palette key is
* account-scoped, and a raw colour may be invalid on the new calendar.

View File

@@ -64,6 +64,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
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.CalendarFabColumn
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.rememberCalendarFadeSpec
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.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
@@ -130,6 +131,13 @@ fun MonthScreen(
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).
var slideDir by remember { mutableIntStateOf(0) }
@@ -186,6 +194,7 @@ fun MonthScreen(
topBar = {
MonthTopBar(
month = month,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
@@ -294,16 +303,18 @@ private fun MonthContent(
@Composable
private fun MonthTopBar(
month: YearMonth,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatMonthYear(month),
text = formatMonthTitle(month, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
)
},
@@ -732,9 +743,10 @@ private fun MonthGridLoading() {
}
}
private fun formatMonthYear(ym: YearMonth): String {
val locale = Locale.getDefault()
val name = java.time.Month.of(ym.month.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, locale)
return "$name ${ym.year}"
}
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = "LLLL",
)

View File

@@ -54,7 +54,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
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.eventFill
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat

View File

@@ -135,7 +135,7 @@ import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
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.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.theme.BundledFont

View File

@@ -77,6 +77,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
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.CalendarFabColumn
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.rememberCalendarFadeSpec
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.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
@@ -180,6 +181,13 @@ fun WeekScreen(
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.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
@@ -228,6 +236,7 @@ fun WeekScreen(
topBar = {
WeekTopBar(
weekStart = weekStart,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
@@ -395,16 +404,18 @@ private fun WeekSuccess(
@Composable
private fun WeekTopBar(
weekStart: LocalDate,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatWeekRange(weekStart),
text = formatWeekTitle(weekStart, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
)
},
@@ -854,18 +865,21 @@ private fun WeekLoading() {
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatWeekRange(weekStart: LocalDate): String {
val locale = Locale.getDefault()
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
val monthName = { d: LocalDate ->
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
}
return if (weekStart.month == end.month && weekStart.year == end.year) {
"${weekStart.day}.${end.day}. ${monthName(weekStart)} ${weekStart.year}"
} else if (weekStart.year == end.year) {
"${weekStart.day}. ${monthName(weekStart)} ${end.day}. ${monthName(end)} ${end.year}"
} else {
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} " +
"${end.day}. ${monthName(end)} ${end.year}"
}
}
/**
* The week's title: just the month [weekStart] falls in.
*
* The day numbers are already in the column headers directly below, so spelling
* 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
* month until it is fully gone — a week is seven contiguous days, so the earlier
* month has a day in it exactly while [weekStart] is still inside it. That also
* makes the title depend on nothing but [weekStart], so it cannot drift with the
* direction you paged in from.
*/
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = "LLLL",
)

View File

@@ -55,7 +55,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
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.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme

View File

@@ -49,6 +49,7 @@ import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
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.layoutMonthWeeks
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 weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
MonthHeader(label = monthLabel(ym))
MonthHeader(label = monthLabel(ym, source.today.year))
Spacer(GlanceModifier.height(2.dp))
WeekdayHeader(weekStart = source.weekStart, colW = colW)
weeks.forEach { week ->
@@ -480,9 +481,13 @@ private fun PermissionMessage() {
}
}
private fun monthLabel(month: YearMonth): String {
val locale = Locale.getDefault()
val name = java.time.Month.of(month.month.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, locale)
return "$name ${month.year}"
}
// Locale.getDefault() rather than currentLocale(): Glance has no
// LocalConfiguration, and the widget is re-rendered on a configuration change
// anyway (same convention as AgendaWidget).
private fun monthLabel(month: YearMonth, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
locale = Locale.getDefault(),
currentYear = currentYear,
skeleton = "LLLL",
)

View File

@@ -99,6 +99,19 @@
<string name="event_edit_availability">Availability</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 -->
<string name="event_edit_color">Color</string>
<string name="event_edit_color_default">Calendar color</string>

View File

@@ -19,7 +19,14 @@ class EventWriteMapperTest {
isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
timezone: String? = null,
): EventForm = EventForm(
calendarId = 1L,
isAllDay = isAllDay,
start = start,
end = end,
timezone = timezone,
)
@Test
fun `timed event resolves wall clock in the given zone`() {
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
assertThat(times.timezone).isEqualTo("Europe/Berlin")
}
@Test
fun `pinned zone wins over the device zone`() {
val times = form(timezone = "America/New_York").toWriteTimes(berlin)
// 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give.
assertThat(times.timezone).isEqualTo("America/New_York")
assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L)
}
@Test
fun `an unparseable pinned zone falls back to the device zone`() {
val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin)
assertThat(times.timezone).isEqualTo("Europe/Berlin")
}
@Test
fun `all-day ignores a pinned zone and stays UTC`() {
val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin)
assertThat(times.timezone).isEqualTo("UTC")
}
@Test
fun `all-day event lives at UTC midnights with exclusive end`() {
val times = form(isAllDay = true).toWriteTimes(berlin)
@@ -105,6 +132,42 @@ class EventWriteMapperTest {
assertThat(update(original, original.copy())).isEmpty()
}
@Test
fun `editing the time of a pinned event keeps its zone`() {
// The regression this guards: the update used to stamp the device zone
// over the event's own, silently un-anchoring a foreign-zone event so it
// stopped tracking that zone across DST.
val original = form(timezone = "America/New_York")
val values = update(original, original.copy(title = "Standup", start = original.start))
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
)
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
.isEqualTo("America/New_York")
}
@Test
fun `changing only the zone still moves the event`() {
// Same wall-clock, different zone: the instant moves, so DTSTART must be
// rewritten even though start/end compare equal.
val original = form()
val values = update(original, original.copy(timezone = "America/New_York"))
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
// 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L)
}
@Test
fun `unpinning back to the device zone rewrites the times`() {
val original = form(timezone = "America/New_York")
val values = update(original, original.copy(timezone = null))
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
}
@Test
fun `text-only edit writes just the changed columns`() {
val original = form()

View File

@@ -117,6 +117,7 @@ class EventFormTest {
attendees: List<Attendee> = emptyList(),
eventColor: Int? = null,
eventColorKey: String? = null,
eventTimezone: String? = null,
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 1L,
@@ -138,6 +139,7 @@ class EventFormTest {
accessLevel = accessLevel,
eventColor = eventColor,
eventColorKey = eventColorKey,
eventTimezone = eventTimezone,
)
@Test
@@ -157,6 +159,117 @@ class EventFormTest {
assertThat(prefilled.description).isEqualTo("Body")
}
@Test
fun `toEditForm leaves an event in the device zone unpinned`() {
val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm(
beginMillis = 1_781_164_800_000L,
endMillis = 1_781_164_800_000L + 3_600_000L,
zone = berlin,
)
// Same zone as the device: pinning it would only make the picker appear
// on every ordinary event.
assertThat(prefilled.timezone).isNull()
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone)
}
@Test
fun `toEditForm pins a foreign zone and shows the times in it`() {
val prefilled = detail(eventTimezone = "America/New_York").toEditForm(
beginMillis = 1_781_164_800_000L, // 08:00Z
endMillis = 1_781_164_800_000L + 3_600_000L,
zone = berlin,
)
assertThat(prefilled.timezone).isEqualTo("America/New_York")
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
// event's own wall-clock, which is what a later save re-anchors to.
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
}
@Test
fun `toEditForm never pins a zone on an all-day event`() {
// All-day rows carry a nominal "UTC" that is an anchor, not a location.
val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm(
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L,
zone = berlin,
)
assertThat(prefilled.timezone).isNull()
}
@Test
fun `toEditForm ignores an unparseable stored zone`() {
val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm(
beginMillis = 1_781_164_800_000L,
endMillis = 1_781_164_800_000L + 3_600_000L,
zone = berlin,
)
// A malformed sync row must not be honoured, nor fail the open.
assertThat(prefilled.timezone).isNull()
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
}
@Test
fun `timesIn converts a pinned event into the target zone`() {
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
timezone = "America/New_York",
)
val (start, end) = form.timesIn(berlin)!!
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
}
@Test
fun `timesIn crosses the date line when the offset pushes past midnight`() {
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)),
timezone = "America/New_York",
)
val (start, _) = form.timesIn(berlin)!!
// 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move
// with it, not just the clock.
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
}
@Test
fun `timesIn tracks each zone's own DST rather than a fixed offset`() {
// In January both are on standard time: 08:00 EST is 14:00 CET — the
// same six hours as July, but only because both shifted. Late March,
// when the US has sprung forward and Europe hasn't, the gap is five.
val march = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)),
timezone = "America/New_York",
)
assertThat(march.timesIn(berlin)!!.first)
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
}
@Test
fun `timesIn returns null when there is nothing to disambiguate`() {
val base = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
)
// Unpinned: the form's times already are the local times.
assertThat(base.timesIn(berlin)).isNull()
// Pinned to the target itself: same thing.
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
// All-day: date-anchored, so there's no zone conversion to show.
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
.isNull()
// Unparseable: can't convert, mustn't throw.
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
}
@Test
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.

View File

@@ -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")
}
}

View File

@@ -99,6 +99,30 @@ on-device — see plan 03):
UTC, so zones ahead of UTC can't leak an extra occurrence.
- All-day events are normalised to UTC midnights with an exclusive end.
### Event time zones
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
"the device zone at save time"** — not "no zone". The data layer resolves it in
`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary
event behaves exactly as it did before the field existed.
- A non-null value **pins** the event: it keeps tracking that zone's offset
across DST no matter where the device is. `toEditForm` only pins when the
stored zone differs from the device's, so the optional Time-zone field stays
hidden on ordinary events and reveals itself (via `populatedFields`) on
foreign-zone ones.
- A pinned event is prefilled **in its own zone**, so the form shows the
wall-clock the event means rather than the device's rendering of it.
- A zone change counts as a **time change** even with the wall-clock untouched
(same 09:00 elsewhere is a different instant), so `buildEventUpdateValues`
includes it in `timesChanged` and rewrites `DTSTART`.
- **All-day events never carry a zone.** They're date-anchored — the UTC
midnights above are an anchor, not a location — so the field is withheld from
the form entirely and `toWriteTimes` forces `"UTC"` regardless.
Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and
`AllDayReminderEncoding`'s offset (see its KDoc).
## Save conflicts
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form

View 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]).