Compare commits

...

30 Commits

Author SHA1 Message Date
426ddf27ee Merge pull request 'fix(agenda): stop yesterday's all-day event surfacing under today (#65)' (!85) from fix/agenda-allday-today into release/v2.16.0
Reviewed-on: #85
2026-07-19 14:14:49 +00:00
4148196a36 fix(agenda): stop yesterday's all-day event surfacing under today (#65)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m53s
The agenda window starts at the anchor's local midnight, which east of UTC
is the previous day's 22:00 UTC. All-day events live at UTC midnights with an
exclusive next-midnight end, so yesterday's all-day event (a birthday, say)
still overlaps today's window start and is returned by the provider.
groupAgendaDays then clamped its first day up to the anchor and, via a trailing
coerceAtLeast(firstDay) on the last day, pulled it onto the anchor's "today"
section — the multi-day fix only stopped the forward leak on interior days.

Drop instances whose true last day (resolved in UTC for all-day events) falls
before the anchor, or whose first day falls past the window end: they occupy no
visible day and must not be clamped onto an edge. Adds eastern-zone regression
tests for both edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:05:36 +02:00
9a1d753abc Merge pull request 'feat(views): optional jump-to-today button in the toolbar (#60)' (!84) from feat/today-button-in-toolbar into release/v2.16.0
Reviewed-on: #84
2026-07-19 10:26:09 +00:00
1b29abed17 docs(changelog): note the toolbar today-button option (#60)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m3s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:24:08 +02:00
85d72ad051 refactor(views): extract shared TodayAction top-bar button
Collapse the today icon-button block that was copy-pasted into all four
calendar top bars (Week/Month/Day/Agenda) into one TodayAction composable in
ui/common. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:18:47 +02:00
fd3bcdf2f7 Merge pull request 'fix(agenda): show multi-day events on every day they span' (!83) from fix/agenda-multiday into release/v2.16.0
Reviewed-on: #83
2026-07-19 10:16:56 +00:00
46afa830b3 feat(views): optional jump-to-today button in the toolbar (#60)
Add a "Today button in toolbar" appearance toggle (default off). When on,
each calendar view (month/week/day/agenda) shows a persistent go-to-today
icon button in its top bar instead of the fade-in extended FAB, and the
FAB pill is suppressed. Same jump action, just always present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:12:54 +02:00
7de9b2f81b fix(agenda): share the day-aware time label with the widget
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m45s
Code-review follow-up.

- The multi-day expansion in groupAgendaDays is shared with the agenda
  widget, but only the screen's summary was made day-aware — so the
  widget rendered the raw "start – end" on every spanned day, the very
  bug the screen fix cured. Hoist a pure agendaTimeLabel(event, day,
  zone) into the shared agenda layer and resolve strings from it in both
  the screen and the widget, so they label identically. (findings 1, 2)

- groupAgendaDays could silently drop an instance whose clamped span was
  empty (firstDay > lastDay); floor lastDay at firstDay so a returned
  instance always surfaces on at least its first visible day. (finding 3)

- agendaTimeLabel resolves the span days once instead of the summary
  recomputing them 2–3× per row. (finding 5)

Finding 4 (within-day sort) needs no change: sorting by absolute start
already places a still-running multi-day event at the top of each day it
continues into, which is chronologically correct (it is ongoing from
that day's midnight), and at its real start time on its first day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:06:40 +02:00
3ec46c8631 fix(agenda): resolve all-day span days in UTC, not the device zone
Review follow-up. spanFirstDay/spanLastDay resolved every event in the
device zone, but all-day events live at UTC midnights with an exclusive
end — east of UTC (e.g. Europe/Berlin) that pushed spanLastDay onto the
next day, so a single-day all-day event reported spansMultipleDays and
leaked onto a second agenda day. Resolve all-day dates in UTC, matching
the Week view and detail card. Adds eastern-zone regression tests that
the prior UTC-only tests could not catch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:53:21 +02:00
529dc9c374 Merge remote-tracking branch 'origin/release/v2.16.0' into fix/agenda-multiday 2026-07-19 11:50:29 +02:00
60ef920b82 Merge pull request 'feat: per-event time zones (#31)' (!82) from feat/timezone-support into release/v2.16.0
Reviewed-on: #82
2026-07-19 09:49:13 +00:00
7b7b859fee docs(changelog): note per-event time zones (#31)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 9m50s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:47:13 +02:00
b5c2930609 feat(agenda): spell out multi-day time line instead of arrows
The "→" arrow convention read as unclear. Spell each day out instead:
the first day names the start ("Starts 14:00"), the last day the end
("Ends 10:00"), and whole days in between read as "All day". All-day
multi-day events stay "All day" on every day. Single-day rows unchanged.

Adds agenda_span_starts / agenda_span_ends (owes Weblate backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:42:15 +02:00
3feafe38f2 feat(agenda): day-aware time line for multi-day events
A multi-day event repeated the same "start – end" on every day it spans
("14:00 – 14:00"), which reads as meaningless. Show only the part
relevant to each day, with a "→" marking that it carries past the day's
boundary: the first day shows the start ("14:00 →"), the last day the
end ("→ 10:00"), and whole days in between an all-day span arriving from
and continuing into their neighbours ("→ All day →"). Single-day rows are
unchanged.

Factors the span first/last-day resolution into shared EventInstance
helpers reused by groupAgendaDays and the label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:39:01 +02:00
19baed9291 fix(agenda): scope LazyColumn key by day for spanning events
A multi-day event now appears under every day it spans, so keying its
row by instanceId alone repeated the key across days and crashed the
LazyColumn ("Key already used") on scroll. Scope the key by day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:30:00 +02:00
4ea68adf8b feat(timezone): let the picker search by abbreviation
Search matched city, id, and the long localized name but not the
abbreviation, so typing "CEST" found nothing. Match it too: an exact
abbreviation hit ranks just under a city prefix, so typing an abbreviation
gathers every zone that shows it (all the CEST zones at once). It matches
the region-resolved abbreviation — i.e. exactly what the row displays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:25:22 +02:00
c3ba8ccf64 test: fix CalendarRepositorySmokeTest against the current constructor
CalendarRepositoryImpl gained a SettingsPrefs parameter, but this
instrumented smoke test still called the three-arg constructor — so the
whole androidTest source set failed to compile. Pass a SettingsPrefs
built on the same DataStore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:19:53 +02:00
7ed1d89b66 fix(agenda): list multi-day events on every day they span
groupAgendaDays keyed each instance by its start day alone, so a
multi-day event surfaced only on its first day and vanished from the
rest of its span in both the Agenda screen and the agenda widget.

Expand each instance across every day from its start (clamped to the
anchor for ongoing events) through its last occupied day, bounded by the
visible window end. An event ending exactly at midnight — including the
exclusive next-midnight all-day events end at — does not reach that
boundary day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:16:08 +02:00
734bcae02d fix(timezone): resolve the abbreviation in the zone's own region
java.util gave abbreviations only for zones in the display locale's region:
an en-DE phone saw "CEST" for Berlin but "GMT-4" for New York, and an
en-US phone the reverse — ICU only surfaces the short names commonly used
where the reader is. Verified on-device (en-DE): US zones fell back to the
offset, EU ones resolved.

Ask ICU in the display language but the *zone's* region instead — New York
in en-US, Berlin in en-DE — using android.icu's zone→region map. On-device
that lights up EDT/PDT/CDT, plus BST, AEST, IST, JST that showed only the
offset before. Where a region still has no name (Athens in en-GR) the
device locale sometimes does, so fall back to it, then to the offset.

The region lookup needs android.icu, which domain/ can't import, so it's
injected: timeZoneOptions takes a regionOf lambda (default none, keeping
the module pure and JVM-tested), and the UI passes icuTimeZoneRegion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:15:48 +02:00
629896b74d fix(timezone): resolve the abbreviation via java.util, not java.time
The abbreviation came from java.time's "zzz" formatter, which on Android
has no short specific-zone names and silently degrades every zone to a
"GMT-4" form — so on device New York showed "GMT-04:00" instead of "EDT".
Desktop couldn't catch it: there "zzz" and java.util.TimeZone agree, and
that agreement is the whole trap.

Resolve through java.util.TimeZone.getDisplayName(inDst, SHORT, locale)
instead — ICU-backed, so it returns the real abbreviation on both Android
and the JVM. Zones with no named abbreviation still fall back to a GMT
form, which zoneDescriptor already collapses to a single token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:01:25 +02:00
01ac61185b fix(timezone): show the IANA id + abbreviation, not the long localized name
"Central European Time (Europe/Berlin)" is too wide — it made the zone
field wrap and stretch. Lead with the id ("Europe/Berlin") and follow it
with the abbreviation instead: "CEST · GMT+02:00" in the picker and edit
card, "CET · 8:00 AM – 9:00 AM" on the detail card (abbreviation + the
event's own-zone time).

The abbreviation is resolved DST-aware at the same instant as the offset
(CET vs CEST) via "zzz"; zones with no named abbreviation fall back to a
"GMT+05:30" form, and zoneDescriptor drops the separate offset in that
case so it isn't stated twice. The long localized name is kept on the
option for search only — typing "pacific" still works — but no longer
shown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:48:21 +02:00
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
42 changed files with 1857 additions and 161 deletions

View File

@@ -5,6 +5,56 @@ 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
### Added
- Give an event its own time zone. A new **Time zone** field (under "more
fields" in the event form) pins an event to a specific zone, so a call set for
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
tracking that zone across daylight-saving changes instead of drifting an hour.
The form edits the event in its own zone and shows the local equivalent under
the times ("2:00 PM 3:00 PM your time"); the event's details keep your local
time first and note the original beneath it, so both are always clear. Pick a
zone from a full-screen picker with your device zone and recent choices on top,
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
("CEST") to gather every matching zone at once. All-day events stay
date-anchored and carry no zone, as before ([#31]).
- Put the "jump to today" button in the toolbar. A new **Today button in
toolbar** setting (Settings → Appearance, off by default) swaps the floating
button that fades into the corner while you're away from today for a permanent
today icon in the top bar — always there, on today or not, matching the
familiar calendar-app pattern. Leave it off to keep the floating button as
before ([#60]).
### 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]).
### Fixed
- An all-day event no longer shows up again the day after it happened. In time
zones east of UTC, an all-day event — a birthday, say — set for one day also
appeared under the *next* day's heading in the Agenda (and the agenda widget),
because all-day events are anchored to UTC midnight and the following day's
window reached back across that boundary and pulled the event forward onto
"today". Each all-day event now lists only on the day it actually falls on
([#65]).
## [2.15.0] — 2026-07-15
### Added
@@ -984,6 +1034,7 @@ automatically, with zero telemetry and no internet permission.
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
@@ -997,3 +1048,5 @@ 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
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65

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

@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
)
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
return CalendarRepositoryImpl(
dataSource,
CalendarPrefs(store),
SettingsPrefs(store),
Dispatchers.IO,
)
}
@Test

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
@@ -233,6 +234,21 @@ class SettingsPrefs @Inject constructor(
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
}
/**
* Where the jump-to-today control lives (issue #60). Default OFF — the
* historical layout, where it's an extended FAB that fades in above the "+"
* only while the view is off today. Turning it ON moves it to a persistent
* icon button in each calendar view's top bar (always shown, on today or
* not) and drops the FAB pill.
*/
val todayButtonInToolbar: Flow<Boolean> = store.data.map { prefs ->
prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false
}
suspend fun setTodayButtonInToolbar(enabled: Boolean) {
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -354,6 +370,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 +686,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(',')
@@ -710,6 +753,7 @@ class SettingsPrefs @Inject constructor(
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
@@ -736,6 +780,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,196 @@
package de.jeanlucmakiola.calendula.domain
import java.text.Normalizer
import java.time.Instant
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
/**
* One selectable zone, resolved for display at a given instant. [id] is the IANA
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
* DST, so they're only meaningful next to the moment they were resolved for.
*
* [displayName] is the long localized name ("Central European Time"). It's kept
* for search only — matching on it lets someone type "pacific" — and is *not*
* displayed: spelled out next to the id it made the field too wide to fit.
*/
data class TimeZoneOption(
val id: String,
val displayName: String,
val shortName: String,
val offsetMinutes: Int,
) {
/** The id as the primary label, underscores undone ("America/New York"). */
val label: String get() = id.replace('_', ' ')
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
}
private fun optionFor(
zone: ZoneId,
locale: Locale,
at: Instant,
regionOf: (String) -> String?,
): TimeZoneOption = TimeZoneOption(
id = zone.id,
displayName = zone.getDisplayName(TextStyle.FULL, locale),
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
)
/**
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
* a "GMT+05:30" form when no name exists.
*
* Two things make this fiddlier than it looks. First, it goes through
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
* agrees with java.util only on desktop, which is why desktop can't catch it).
* Second, ICU only surfaces the short name *commonly used in the display
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
* en-DE) via [regionOf]. That covers almost everything; where a region still
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
* try that next; failing both, the caller shows the offset.
*
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
* needs `android.icu`, which this pure-JVM module can't import. The default
* (no region) leaves only the device-locale path — which is all a JVM test has
* anyway, and enough there since desktop ICU isn't region-gated the same way.
*/
private fun resolveAbbreviation(
id: String,
dst: Boolean,
locale: Locale,
regionOf: (String) -> String?,
): String {
fun shortIn(loc: Locale): String =
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
val regional = regionOf(id)
?.takeIf { it.length == 2 }
?.let { shortIn(Locale(locale.language, it)) }
if (regional != null && !regional.looksLikeOffset()) return regional
// Either no region, or the region has no name for this zone: the device
// locale is the next-best shot, and the offset the last resort.
return shortIn(locale)
}
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
/**
* Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it
* once and filter the result rather than rebuilding per keystroke. [regionOf]
* (see [resolveAbbreviation]) supplies the zone's region for the abbreviation.
*
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
* dropped: they're aliases the tz database keeps for compatibility, they'd
* double up the real zones in the list, and none of them is what a user means
* when they pick a place.
*/
fun timeZoneOptions(
locale: Locale = Locale.getDefault(),
at: Instant = Instant.now(),
regionOf: (String) -> String? = { null },
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
.asSequence()
.filter { it.contains('/') && !it.startsWith("SystemV/") }
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
.sortedWith(compareBy({ it.region }, { it.city }))
.toList()
/**
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
* database doesn't know it — for labelling one known zone without paying to
* build the whole catalogue.
*/
fun timeZoneOptionOf(
id: String,
locale: Locale = Locale.getDefault(),
at: Instant = Instant.now(),
regionOf: (String) -> String? = { null },
): TimeZoneOption? {
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
return optionFor(zone, locale, at, regionOf)
}
/**
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
* fine alone; any other offset-shaped name is normalised to our own GMT form so
* the offset isn't stated twice in ICU's spelling and ours.
*/
fun zoneDescriptor(option: TimeZoneOption): String {
val abbrev = option.shortName
return when {
abbrev == "UTC" -> "UTC"
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
}
}
/**
* [options] matching [query], best matches first; a blank query returns the list
* unchanged. Matching is accent- and case-insensitive and treats underscores as
* spaces, so "sao paulo" finds "America/Sao_Paulo".
*
* Ranking puts a city that *starts with* the query above one that merely
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
* and the id is matched ahead of the localized name so a user who knows the
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
* city prefix, so typing an abbreviation surfaces every zone that shows it
* (all the CEST zones together); the abbreviation is the one resolved for the
* display region, i.e. what the row actually shows.
*/
fun filterTimeZones(options: List<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 abbrev = option.shortName.normalizeForSearch()
val rank = when {
city.startsWith(needle) -> 0
abbrev == needle -> 1
name.startsWith(needle) -> 2
abbrev.startsWith(needle) -> 3
city.contains(needle) -> 4
id.contains(needle) -> 5
name.contains(needle) -> 6
else -> return@mapNotNull null
}
rank to option
}
.sortedWith(compareBy({ it.first }, { it.second.city }))
.map { it.second }
}
/**
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
* a query types the way a place is spoken rather than the way the tz database
* spells it.
*/
private fun String.normalizeForSearch(): String =
Normalizer.normalize(this, Normalizer.Form.NFD)
.replace(Regex("\\p{Mn}+"), "")
.replace('_', ' ')
.replace('/', ' ')
.lowercase(Locale.ROOT)
.trim()
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
fun formatGmtOffset(offsetMinutes: Int): String {
if (offsetMinutes == 0) return "GMT"
val sign = if (offsetMinutes < 0) '-' else '+'
val abs = kotlin.math.abs(offsetMinutes)
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
}

View File

@@ -88,6 +88,8 @@ fun CalendarHost(
// sensible non-empty initial values, so they're ready before the first frame.
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
// Whether the jump-to-today control sits in each view's top bar or the FAB (#60).
val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
@@ -318,6 +320,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
@@ -329,6 +332,7 @@ fun CalendarHost(
initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
@@ -339,6 +343,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
@@ -350,6 +355,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
}
}

View File

@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
}

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

@@ -65,10 +65,11 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
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 +77,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
@@ -102,6 +103,7 @@ fun AgendaScreen(
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
) {
@@ -151,12 +153,14 @@ fun AgendaScreen(
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday,
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday,
onCreate = { onCreateEvent(anchor, null) },
@@ -367,10 +371,14 @@ private fun AgendaList(
} else {
itemsIndexed(
items = day.events,
key = { _, event -> event.instanceId },
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
@@ -449,6 +457,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) {
@Composable
private fun AgendaEventRow(
event: EventInstance,
day: LocalDate,
position: Position,
dimmed: Boolean,
modifier: Modifier = Modifier,
@@ -460,7 +469,7 @@ private fun AgendaEventRow(
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title,
summary = agendaTimeSummary(event),
summary = agendaTimeSummary(event, day),
position = position,
minHeight = 64.dp,
leading = {
@@ -504,6 +513,8 @@ private fun AgendaTopBar(
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -522,6 +533,7 @@ private fun AgendaTopBar(
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
@@ -554,16 +566,29 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
return if (relative != null) "$relative · $formatted" else formatted
}
/** Time line under the title: "09:00 10:00 · Location", "All day", etc. */
/**
* Time line under the title: "09:00 10:00 · Location", "All day", etc.
*
* A multi-day event shows only the part relevant to [day], spelled out so each
* day reads on its own: its first day names the start ("Starts 14:00"), its last
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
* An all-day multi-day event is simply "All day" on every day it covers.
*/
@Composable
private fun agendaTimeSummary(event: EventInstance): String {
val time = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} else {
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
"${formatTime(event.start, is24Hour, locale)} ${formatTime(event.end, is24Hour, locale)}"
private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String {
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val time = when (val label = agendaTimeLabel(event, day, zone)) {
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts ->
stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale))
is AgendaTimeLabel.Ends ->
stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale))
is AgendaTimeLabel.Range ->
"${formatTime(label.start, is24Hour, locale)} ${formatTime(label.end, is24Hour, locale)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}

View File

@@ -2,9 +2,70 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* The zone the event's calendar dates live in. Timed events are resolved in the
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
* a one-day event onto its next day. Matches the Week view and detail card.
*/
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
if (isAllDay) TimeZone.UTC else zone
/** The first calendar day this event occupies. */
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(dateZone(zone)).date
/**
* The last calendar day this event actually occupies. An event ending exactly at
* midnight (all-day events end at the exclusive next-midnight) does not reach
* into that boundary day, so resolve the instant just before [end].
*/
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start
return lastInstant.toLocalDateTime(dateZone(zone)).date
}
/** Whether this event occupies more than one calendar day in [zone]. */
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
/**
* What an agenda row's time line should convey for an event on a given day —
* the part of a multi-day span that [day] falls in. Pure and shared so the
* agenda screen and the agenda widget label multi-day events identically; each
* surface only formats the instants into its own locale/24h string.
*/
sealed interface AgendaTimeLabel {
/** An all-day event, or a whole in-between day of a multi-day span. */
data object AllDay : AgendaTimeLabel
/** The first day of a multi-day timed event: when it begins. */
data class Starts(val start: Instant) : AgendaTimeLabel
/** The last day of a multi-day timed event: when it ends. */
data class Ends(val end: Instant) : AgendaTimeLabel
/** A single-day timed event: its startend range. */
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
}
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
if (event.isAllDay) return AgendaTimeLabel.AllDay
val firstDay = event.spanFirstDay(zone)
val lastDay = event.spanLastDay(zone)
return when {
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
else -> AgendaTimeLabel.AllDay // a full in-between day
}
}
/** One calendar day with at least one event, for the agenda list. */
data class AgendaDay(
@@ -15,31 +76,51 @@ data class AgendaDay(
/**
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
* actually carry events). An event that began before [anchor] (ongoing or
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
* day, all-day events sort first, then ascending by start time, then title.
* actually carry events). A multi-day event surfaces on *every* day it spans,
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
* before the window (ongoing) still lists from the anchor day, and one running
* past the window stops at the last visible day. Within a day, all-day events
* sort first, then ascending by start time, then title.
*
* Shared by the Agenda screen and the agenda home-screen widget so both group
* and order identically.
*/
fun groupAgendaDays(
anchor: LocalDate,
windowEnd: LocalDate,
instances: List<EventInstance>,
zone: TimeZone,
): List<AgendaDay> =
instances
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
.toSortedMap()
.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
)
): List<AgendaDay> {
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
for (instance in instances) {
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
// The provider returns an event whenever its instant span overlaps the query
// window, but all-day events live at UTC midnights with an exclusive end: east
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
// overlaps today's window start and comes back even though its true last day
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
// surface it under "today" (issue #65); drop it instead. The symmetric case —
// a next-day all-day event overlapping the window's last instant — drops too.
if (lastDay < firstDay) continue
var day = firstDay
while (day <= lastDay) {
byDay.getOrPut(day) { mutableListOf() }.add(instance)
day = day.plus(1, DateTimeUnit.DAY)
}
}
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
)
}
}
/**
* Ensure [today] surfaces as the first agenda day even when it carries no

View File

@@ -159,11 +159,11 @@ class AgendaViewModel @Inject constructor(
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone)
val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
return AgendaUiState.Success(
anchor = anchor,
today = todayDate,

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.timeZoneOptions
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
/**
* Full-screen zone picker: a query field over every zone the JVM knows, with the
* device zone and recently-picked ones pinned on top so the common case needs no
* typing. [selected] is the pinned zone id, or null when the event follows the
* device — picking the device row reports null back, which is what keeps an
* unpinned event unpinned rather than freezing it to today's zone.
*
* Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this
* drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far
* past what an eagerly composed column can carry.
*/
@Composable
fun TimeZonePickerDialog(
selected: String?,
deviceZoneId: String,
recents: List<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, regionOf = ::icuTimeZoneRegion) }
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
val recentZones = remember(allZones, recents) {
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
}
val searching = query.isNotBlank()
fun choose(zoneId: String?) {
onSelect(zoneId)
onDismiss()
}
FullScreenPicker(
title = stringResource(R.string.event_detail_timezone),
onDismiss = onDismiss,
scrollable = false,
) {
SearchField(
query = query,
onQueryChange = { query = it },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
if (!searching) {
// The device row is the "unpinned" choice, so it reads as a mode
// rather than as a zone among zones — it stays out of the big
// list and never carries an offset.
item(key = "device") {
GroupedRow(
title = stringResource(R.string.event_edit_timezone_device),
summary = zoneSummary(deviceZoneId, allZones),
position = Position.Alone,
selected = selected == null,
leading = { Icon(Icons.Default.Public, contentDescription = null) },
trailing = if (selected == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { choose(null) },
)
}
if (recentZones.isNotEmpty()) {
item(key = "recent_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
}
itemsIndexed(
items = recentZones,
key = { _, zone -> "recent_${zone.id}" },
) { index, zone ->
ZoneRow(
zone = zone,
position = positionOf(index, recentZones.size),
selected = zone.id == selected,
onClick = { choose(zone.id) },
)
}
}
item(key = "all_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_all))
}
}
if (searching && matches.isEmpty()) {
item(key = "empty") {
Text(
text = stringResource(R.string.event_edit_timezone_none, query),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 48.dp),
)
}
}
itemsIndexed(
items = matches,
key = { _, zone -> "zone_${zone.id}" },
) { index, zone ->
ZoneRow(
zone = zone,
position = positionOf(index, matches.size),
selected = zone.id == selected,
onClick = { choose(zone.id) },
)
}
}
}
}
/**
* The query box: the family's borderless [InlineTextField] over a tonal surface,
* so it reads like the rest of the app's inputs rather than a boxed Material
* field. The clear button rides inside the surface (unlike the search screen,
* which has a top bar to put it in) because the picker's bar is the title.
*/
@Composable
private fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val keyboard = LocalSoftwareKeyboardController.current
// surfaceContainerHighest — the picker sits on surfaceContainerHigh, so
// anything lower vanishes into it.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(28.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp),
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
InlineTextField(
value = query,
onValueChange = onQueryChange,
placeholder = stringResource(R.string.event_edit_timezone_search),
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp, vertical = 14.dp),
)
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/** A small primary-coloured group label, matching the settings screens. */
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun SelectedCheck() {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
@Composable
private fun ZoneRow(
zone: TimeZoneOption,
position: Position,
selected: Boolean,
onClick: () -> Unit,
) {
GroupedRow(
title = zone.label,
summary = zoneDescriptor(zone),
position = position,
selected = selected,
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick,
)
}
/** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */
private fun zoneSummary(zoneId: String, allZones: List<TimeZoneOption>): String =
allZones.firstOrNull { it.id == zoneId }
?.let { zoneDescriptor(it) }
?: zoneId

View File

@@ -0,0 +1,16 @@
package de.jeanlucmakiola.calendula.ui.common
import android.icu.util.TimeZone as IcuTimeZone
/**
* The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or
* null when ICU has none or maps it to the multi-country "001" ("Etc/UTC").
*
* This is the `android.icu`-backed bridge the pure-JVM
* [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so
* it can ask ICU for a zone's abbreviation in that zone's own region — the only
* region for which ICU will surface it (see `resolveAbbreviation`). Lives here,
* not in `domain/`, precisely because it needs an Android import.
*/
fun icuTimeZoneRegion(id: String): String? =
runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 }

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/**
* The top-bar "jump to today" icon button, shared by every calendar view's app
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
* today control into the toolbar; renders nothing when [show] is false, so it
* drops straight into an app bar's `actions` slot without a wrapping condition.
*/
@Composable
fun TodayAction(show: Boolean, onToday: () -> Unit) {
if (!show) return
IconButton(onClick = onToday) {
Icon(
imageVector = Icons.Default.Today,
contentDescription = stringResource(R.string.today_jump_action),
)
}
}

View File

@@ -68,8 +68,10 @@ 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.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -83,7 +85,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 +95,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
@@ -125,6 +129,7 @@ fun DayScreen(
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
@@ -157,6 +162,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,16 +217,19 @@ fun DayScreen(
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday,
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(date, null) },
@@ -360,16 +375,20 @@ private fun DaySuccess(
@Composable
private fun DayTopBar(
date: LocalDate,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatDayTitle(date),
text = formatDayTitle(date, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
)
},
@@ -382,6 +401,7 @@ private fun DayTopBar(
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
@@ -669,10 +689,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

@@ -93,12 +93,16 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard
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
@@ -108,7 +112,6 @@ import kotlinx.datetime.TimeZone
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
@@ -453,14 +456,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
}
// Time zone — only when the event is timed and pinned to a zone other
// than the device's, so cross-zone events read unambiguously.
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
// than the device's, so cross-zone events read unambiguously. It answers
// "when was it set", which the zone's name alone never did: an 8 AM New
// York call showing as 2 PM here should still say 8 AM somewhere.
//
// Same hierarchy as the When card above — the label carries the card,
// the time sits small beneath it. The local time is the one the reader
// acts on, so the original must stay quieter than it, not compete.
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) {
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale)
}
foreignZone?.let { zoneOption ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone),
) {
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
// The id is the primary label ("Europe/Berlin"); the long
// localized name spelled next to it made the field too wide.
Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
// Beneath, small: the abbreviation plus the event's own-zone time,
// e.g. "CET · 8:00 AM 9:00 AM". formatWhen puts the range in the
// secondary half only for a same-day event; one spanning midnight
// — which a cross-zone event easily does — carries the whole span
// in the primary instead, so fall back to it. If the zone can't be
// read back at all, drop to just the abbreviation + offset.
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
?.let { formatWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp))
Text(
text = if (originalTime != null) {
"${zoneOption.shortName} · $originalTime"
} else {
zoneDescriptor(zoneOption)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -752,21 +785,15 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/**
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
* but only when the event is timed and pinned to a zone different from the
* device's. Returns null when there's nothing worth showing.
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
* pinned to a zone different from the device's — the cases where showing it
* removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
* tz database doesn't know).
*/
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? {
if (isAllDay || tz.isNullOrBlank()) return null
val deviceZone = ZoneId.systemDefault().id
if (tz == deviceZone) return null
return try {
val zone = ZoneId.of(tz)
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
if (name == tz) tz else "$name ($tz)"
} catch (e: Exception) {
tz
}
if (tz == ZoneId.systemDefault().id) return null
return timeZoneOptionOf(tz, locale, regionOf = ::icuTimeZoneRegion)
}
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */

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

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,8 +64,10 @@ 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.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -79,7 +81,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
@@ -105,6 +107,7 @@ fun MonthScreen(
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
) {
@@ -130,6 +133,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,16 +196,19 @@ fun MonthScreen(
topBar = {
MonthTopBar(
month = month,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnCurrentMonth,
todayVisible = !isOnCurrentMonth && !todayInToolbar,
todayText = stringResource(R.string.month_today_action),
onToday = jumpToToday,
onCreate = {
@@ -294,16 +307,20 @@ private fun MonthContent(
@Composable
private fun MonthTopBar(
month: YearMonth,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatMonthYear(month),
text = formatMonthTitle(month, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
)
},
@@ -316,6 +333,7 @@ private fun MonthTopBar(
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
@@ -732,9 +750,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
@@ -571,6 +571,18 @@ private fun AppearanceScreen(
position = Position.Top,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_today_toolbar),
summary = stringResource(R.string.settings_today_toolbar_summary),
position = Position.Middle,
trailing = {
Switch(
checked = state.todayButtonInToolbar,
onCheckedChange = viewModel::setTodayButtonInToolbar,
)
},
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
)
GroupedRow(
title = stringResource(R.string.settings_week_start),
summary = weekStartLabel(state.weekStart),

View File

@@ -37,6 +37,8 @@ data class SettingsUiState(
val dimCompletedEvents: Boolean = false,
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
val showWeekNumbers: Boolean = false,
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
val todayButtonInToolbar: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */

View File

@@ -124,8 +124,9 @@ class SettingsViewModel @Inject constructor(
prefs.showWeekNumbers,
prefs.agendaShowToday,
prefs.softenCalendarColors,
) { hourLines, weekNumbers, showToday, soften ->
DisplayToggles(hourLines, weekNumbers, showToday, soften)
prefs.todayButtonInToolbar,
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
@@ -134,6 +135,7 @@ class SettingsViewModel @Inject constructor(
showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar,
)
},
combine(
@@ -159,6 +161,7 @@ class SettingsViewModel @Inject constructor(
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -234,6 +237,7 @@ class SettingsViewModel @Inject constructor(
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
)
private data class DisplayToggles(
@@ -241,6 +245,7 @@ class SettingsViewModel @Inject constructor(
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
)
private data class MiscSettings(
@@ -460,6 +465,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
}
fun setTodayButtonInToolbar(enabled: Boolean) {
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
}
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)

View File

@@ -77,8 +77,10 @@ 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.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -93,7 +95,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
@@ -143,6 +145,7 @@ fun WeekScreen(
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) {
@@ -180,6 +183,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,16 +238,19 @@ fun WeekScreen(
topBar = {
WeekTopBar(
weekStart = weekStart,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnCurrentWeek,
todayVisible = !isOnCurrentWeek && !todayInToolbar,
todayText = stringResource(R.string.week_today_action),
onToday = jumpToToday,
onCreate = {
@@ -395,16 +408,20 @@ private fun WeekSuccess(
@Composable
private fun WeekTopBar(
weekStart: LocalDate,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatWeekRange(weekStart),
text = formatWeekTitle(weekStart, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
)
},
@@ -417,6 +434,7 @@ private fun WeekTopBar(
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
@@ -854,18 +872,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

@@ -131,13 +131,14 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
// the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, instances, zone),
days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = is24Hour,
soften = soften,
weekStart = weekStart,

View File

@@ -49,13 +49,15 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
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
@@ -127,7 +129,7 @@ class RefreshAgendaAction : ActionCallback {
/** Flat row model so the [LazyColumn] can mix day headers and events. */
private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(val event: EventInstance) : AgendaRow
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */
data class Placeholder(val date: LocalDate) : AgendaRow
}
@@ -185,7 +187,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(it)) }
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
}
}
}
@@ -196,6 +198,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
is AgendaRow.Event -> EventRow(
event = row.event,
day = row.date,
dark = dark,
soften = data.soften,
is24Hour = data.is24Hour,
@@ -312,6 +315,7 @@ private fun PlaceholderRow(date: LocalDate) {
@Composable
private fun EventRow(
event: EventInstance,
day: LocalDate,
dark: Boolean,
soften: Boolean,
is24Hour: Boolean,
@@ -357,7 +361,7 @@ private fun EventRow(
style = TextStyle(color = titleColor, fontSize = 14.sp),
)
Text(
text = eventTimeSummary(context, event, is24Hour),
text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
@@ -396,11 +400,20 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
return if (relative != null) "$relative · $formatted" else formatted
}
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
val time = if (event.isAllDay) {
context.getString(R.string.event_detail_all_day)
} else {
"${formatTime(event.start, is24Hour)} ${formatTime(event.end, is24Hour)}"
private fun eventTimeSummary(
context: Context,
event: EventInstance,
day: LocalDate,
is24Hour: Boolean,
): String {
val time = when (val label = agendaTimeLabel(event, day, zone())) {
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts ->
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
is AgendaTimeLabel.Ends ->
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
is AgendaTimeLabel.Range ->
"${formatTime(label.start, is24Hour)} ${formatTime(label.end, is24Hour)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time

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>
@@ -257,6 +270,12 @@
<string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">You\'re all caught up</string>
<string name="agenda_no_more_today">No more events today</string>
<!-- Time line for a multi-day event, on its first / last day (%1$s is a time, e.g. "14:00"). -->
<string name="agenda_span_starts">Starts %1$s</string>
<string name="agenda_span_ends">Ends %1$s</string>
<!-- Jump to today (toolbar icon button; see settings_today_toolbar) -->
<string name="today_jump_action">Go to today</string>
<!-- Event search -->
<string name="search_action">Search</string>
@@ -308,6 +327,8 @@
<string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_numbers">Week numbers</string>
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
<string name="settings_today_toolbar">Today button in toolbar</string>
<string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string>
<string name="settings_time_format">Time format</string>
<string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</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,159 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.Instant
import java.util.Locale
class TimeZoneCatalogTest {
// A fixed instant so DST-dependent offsets can't drift with the wall clock:
// 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT.
private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z")
private val zones = timeZoneOptions(Locale.ENGLISH, summer)
private fun filter(query: String) = filterTimeZones(zones, query)
@Test
fun `catalogue holds the real zones and drops the legacy aliases`() {
assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York")
// Bare aliases the tz database keeps for compatibility would double up
// the real zones in the picker.
assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC")
assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue()
}
@Test
fun `option exposes city and region split from the id`() {
val ny = zones.first { it.id == "America/New_York" }
assertThat(ny.city).isEqualTo("New York")
assertThat(ny.region).isEqualTo("America")
}
@Test
fun `label is the id with underscores undone`() {
assertThat(zones.first { it.id == "America/New_York" }.label)
.isEqualTo("America/New York")
assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin")
}
@Test
fun `resolved abbreviation is compact, not the long name`() {
// Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be
// short and space-free — that's the whole point of showing it instead of
// "Central European Time".
val berlin = zones.first { it.id == "Europe/Berlin" }
assertThat(berlin.shortName).doesNotContain(" ")
assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length)
}
@Test
fun `descriptor pairs a named abbreviation with the offset`() {
val option = TimeZoneOption(
id = "Europe/Berlin",
displayName = "Central European Time",
shortName = "CET",
offsetMinutes = 60,
)
assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00")
}
@Test
fun `descriptor drops the offset when the abbreviation already is one`() {
// Repeating the offset after an offset-shaped abbreviation would just say
// the same thing twice.
fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor(
TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset),
)
assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC")
assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30")
}
@Test
fun `offset is resolved at the given instant, not the current one`() {
val berlin = zones.first { it.id == "Europe/Berlin" }
// CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST.
assertThat(berlin.offsetMinutes).isEqualTo(120)
val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z"))
assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60)
}
@Test
fun `blank query returns everything unchanged`() {
assertThat(filter("")).isEqualTo(zones)
assertThat(filter(" ")).isEqualTo(zones)
}
@Test
fun `query matches the city, ignoring case and underscores`() {
assertThat(filter("new york").map { it.id }).contains("America/New_York")
assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York")
assertThat(filter("new_york").map { it.id }).contains("America/New_York")
}
@Test
fun `query matches accented cities typed plainly`() {
// Sao_Paulo has no accent in the id, but its localized name does — the
// point is that a user typing plain ASCII still finds it.
assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo")
assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich")
}
@Test
fun `query matches the full IANA id`() {
assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin")
}
@Test
fun `query matches the abbreviation, case-insensitively`() {
// "cest" isn't a city, id, or substring of "Central European Summer
// Time", so the only way these match is via the abbreviation.
val hits = filter("cest")
assertThat(hits).isNotEmpty()
assertThat(hits.map { it.id }).contains("Europe/Berlin")
// Every hit genuinely carries that abbreviation — nothing bled in.
assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue()
// Case doesn't matter.
assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id })
}
@Test
fun `a city starting with the query outranks one merely containing it`() {
val ids = filter("york").map { it.id }
// "New York" contains "york"; nothing starts with it, so it should still
// surface rather than being buried.
assertThat(ids).contains("America/New_York")
// "col" starts Colombo but only appears mid-string elsewhere.
val col = filter("col").map { it.id }
assertThat(col.first()).isEqualTo("Asia/Colombo")
}
@Test
fun `no match yields an empty list rather than everything`() {
assertThat(filter("zzzznotazone")).isEmpty()
}
@Test
fun `single zone resolves the same way the catalogue does`() {
val fromCatalogue = zones.first { it.id == "Europe/Berlin" }
assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer))
.isEqualTo(fromCatalogue)
}
@Test
fun `an unknown zone id resolves to null`() {
assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull()
}
@Test
fun `gmt offsets format with a sign and padding`() {
assertThat(formatGmtOffset(0)).isEqualTo("GMT")
assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00")
assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00")
// India is +05:30 — a whole-hour assumption would render this wrong.
assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30")
assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30")
}
}

View File

@@ -0,0 +1,76 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class AgendaTimeLabelTest {
private val zone = TimeZone.UTC
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
LocalDateTime(y, mo, d, h, min).toInstant(zone)
private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance(
instanceId = 1,
eventId = 1,
calendarId = 1,
title = "e",
start = start,
end = end,
isAllDay = isAllDay,
color = 0,
location = null,
)
private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) =
agendaTimeLabel(event, LocalDate(y, mo, d), zone)
@Test
fun `a single-day timed event is a start-end range`() {
val e = event(at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
assertThat(labelOn(2026, 7, 2, e))
.isEqualTo(AgendaTimeLabel.Range(e.start, e.end))
}
@Test
fun `a single-day all-day event is all-day`() {
val e = event(at(2026, 7, 2), at(2026, 7, 3), isAllDay = true)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
}
@Test
fun `a multi-day timed event names the start, middle, and end days`() {
val e = event(at(2026, 7, 1, 14, 0), at(2026, 7, 4, 10, 0))
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.Starts(e.start))
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
}
@Test
fun `a multi-day all-day event is all-day on every day`() {
val e = event(at(2026, 7, 2), at(2026, 7, 5), isAllDay = true)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.AllDay)
}
@Test
fun `an event begun before the shown day is not labelled as starting`() {
// Runs 29 Jun 09:00 → 2 Jul 09:00; on 1 Jul it is mid-span, on 2 Jul it ends.
val e = event(at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
}
}

View File

@@ -0,0 +1,175 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class GroupAgendaDaysTest {
private val zone = TimeZone.UTC
private val anchor = LocalDate(2026, 7, 1)
// A wide window so clamping to the window end is out of the way unless tested.
private val windowEnd = LocalDate(2026, 7, 31)
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
LocalDateTime(y, mo, d, h, min).toInstant(zone)
private fun event(
id: Long,
title: String,
start: Instant,
end: Instant,
isAllDay: Boolean = false,
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1,
title = title,
start = start,
end = end,
isAllDay = isAllDay,
color = 0,
location = null,
)
private fun days(instances: List<EventInstance>) =
groupAgendaDays(anchor, windowEnd, instances, zone)
@Test
fun `a timed multi-day event lists on every day it spans`() {
val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0))
val result = days(listOf(e))
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
).inOrder()
result.forEach { assertThat(it.events).containsExactly(e) }
}
@Test
fun `an all-day multi-day event stops before its exclusive next-midnight end`() {
// 13 July inclusive: end is the exclusive midnight opening 4 July.
val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
val result = days(listOf(e))
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
).inOrder()
}
@Test
fun `a single-day event lists exactly once`() {
val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
assertThat(days(listOf(e)).map { it.date })
.containsExactly(LocalDate(2026, 7, 2))
}
@Test
fun `an event ending exactly at midnight does not reach the next day`() {
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
assertThat(days(listOf(e)).map { it.date })
.containsExactly(LocalDate(2026, 7, 1))
}
@Test
fun `an event begun before the anchor is clamped to the anchor day`() {
val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
assertThat(days(listOf(e)).map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
).inOrder()
}
@Test
fun `an event running past the window is clamped to the last visible day`() {
val narrowEnd = LocalDate(2026, 7, 2)
val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0))
val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone)
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
).inOrder()
}
// All-day events are stored at UTC midnights; resolving them in a device
// zone east of UTC would leak them onto the following day (see spanLastDay).
private val berlin = TimeZone.of("Europe/Berlin")
private fun utcMidnight(y: Int, mo: Int, d: Int): Instant =
LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC)
@Test
fun `a single-day all-day event does not leak onto the next day in an eastern zone`() {
val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true)
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2))
assertThat(e.spansMultipleDays(berlin)).isFalse()
}
@Test
fun `an all-day multi-day event spans its true days in an eastern zone`() {
// 24 July inclusive: exclusive end is the UTC midnight opening 5 July.
val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true)
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
LocalDate(2026, 7, 4),
).inOrder()
}
@Test
fun `yesterday's all-day event does not surface under the anchor day in an eastern zone`() {
// The agenda window starts at the anchor's local midnight, which in Berlin is
// 22:00 UTC the day before — so an all-day event that ended yesterday (whose
// exclusive UTC-midnight end sits at that instant) is still returned by the
// provider. It must not be clamped onto the anchor's "today" section (#65).
val jul19 = LocalDate(2026, 7, 19)
val e = event(1, "birthday", utcMidnight(2026, 7, 18), utcMidnight(2026, 7, 19), isAllDay = true)
val result = groupAgendaDays(jul19, LocalDate(2026, 8, 18), listOf(e), berlin)
assertThat(result).isEmpty()
}
@Test
fun `an all-day event that falls entirely past the window end is dropped`() {
// The same guard, at the far edge: an instance whose true days are all beyond
// windowEnd occupies no visible day and must not be clamped onto the last one.
val e = event(1, "birthday", utcMidnight(2026, 7, 3), utcMidnight(2026, 7, 4), isAllDay = true)
val result = groupAgendaDays(anchor, LocalDate(2026, 7, 2), listOf(e), berlin)
assertThat(result).isEmpty()
}
@Test
fun `within a day all-day events sort before timed ones`() {
val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true)
val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0))
val dayOne = days(listOf(timed, allDay)).first { it.date == anchor }
assertThat(dayOne.events).containsExactly(allDay, timed).inOrder()
}
}

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,32 @@
### Added
- Give an event its own time zone. A new Time zone field (under "more fields" in
the event form) pins an event to a specific zone, so a call set for 8:00 AM in
New York stays 8:00 AM in New York wherever you open it, and keeps tracking
that zone across daylight-saving changes. The form shows the local equivalent
under the times, and the event's details keep your local time first with the
original noted beneath. Pick a zone from a searchable full-screen picker — by
city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored
and carry no zone ([#31]).
- Put the "jump to today" button in the toolbar. A new Today button in toolbar
setting (Settings → Appearance, off by default) swaps the floating corner
button for a permanent today icon in the top bar — always there, matching the
familiar calendar-app pattern. Leave it off to keep the floating button ([#60]).
### 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]).