Compare commits

...

81 Commits

Author SHA1 Message Date
ada6976e85 Merge pull request 'fix(edit): recurrence picker review fixes + picker unification (#42)' (!89) from feat/recurrence-exdate into release/v2.16.0
Reviewed-on: #89
2026-07-20 13:29:15 +00:00
6a454e0b33 chore: point floret-kit at merged main
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m40s
floret-kit!2 (public SelectedCheck) is merged, so the submodule no longer
needs to track the feature branch. Same tree, so nothing rebuilds — this
only stops Calendula pinning a branch that is now deletable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:28:05 +02:00
cd6ae31d0f Merge remote-tracking branch 'origin/release/v2.16.0' into feat/recurrence-exdate
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 11m20s
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
2026-07-20 15:24:05 +02:00
463811056d refactor(ui): one selection check and one motion spec across the pickers
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m57s
Three inconsistencies the recurrence-picker review surfaced, all of the
same kind: the redesigned picker had quietly diverged from the family.

SelectedCheck existed as five copies app-side plus a private one in the
kit, and they had drifted — the kit drew Icons.Rounded.Check, every app
copy drew Icons.Default.Check. Delete all five against the kit's now
public primitive (floret-kit!2), which settles the glyph on Rounded.

The recurrence picker was the only full-screen picker whose selected row
carried no check, relying on the tonal highlight alone. Add it to all six
rows, matching OptionPicker, reminder, agenda range, timezone, calendar
and Settings.

Its two new AnimatedVisibility blocks were the only ones in the app using
Compose's bare defaults rather than expandEnter()/collapseExit(). Those
helpers honour rememberReduceMotion(), so the weekday and count cards
were ignoring the system "remove animations" setting — an accessibility
regression, not just a style drift.

Bumps the floret-kit pointer; needs floret-kit!2 merged first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:20:37 +02:00
98d76deee5 fix(edit): correct the recurrence picker's read-out and end handling
Review follow-ups to the picker redesign, plus the bugs it exposed.

The live read-out now renders customResult itself — the exact string OK
would save — instead of rebuilding a parallel rule with `interval ?: 1`
and `end ?: Never` fallbacks, which let it confidently describe a rule
that differed from the selected controls whenever a field was invalid.

Shrink the invalid space behind it: a blank amount field reads as its
visible placeholder (1 / 10) rather than as an error, and backing out of
the date picker falls back to "never" instead of stranding a dateless
"on a date". Only an out-of-range 0 remains invalid, and that now says so
rather than greying out OK with no cause.

UNTIL displayed the day after the one picked for zones behind UTC:
toRRule deliberately writes the end of the chosen *local* day expressed
in UTC (the provider applies UNTIL coarsely), so the read side must
convert back before taking the date. Fixes the detail screen too, and
untilLocalDate is extracted so it can be tested.

Also: hoist a remember() out of a conditional (a slot that appears and
disappears breaks positional memoisation), match GroupedSurface's 22dp
corners instead of a drifted local 20dp copy, move the cards onto
GroupedSurface, drop the segmented row's icon slot so longer unit labels
fit, and reserve two lines so the stack stops shifting as the phrase
grows with each weekday.

Extract SettingsPrefs.firstDayOfWeek(scope), replacing three copies that
had drifted onto different initialValues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:20:15 +02:00
20e18768eb Merge pull request 'fix(widget): scale the agenda widget with its size (#51)' (!88) from fix/agenda-widget-size-scaling into release/v2.16.0
Reviewed-on: #88
2026-07-20 13:11:17 +00:00
a34f29bcf8 fix(widget): address review of the agenda size scaling (#51)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m52s
Follow-up to 30fcbfa, fixing eight issues found in review:

- Cap the agenda row list at 100. SizeMode.Exact asks Glance for one
  RemoteViews per host size where SizeMode.Single produced exactly one,
  roughly doubling the payload; with the range reaching
  AgendaRange.MAX_CUSTOM_DAYS (365) an uncapped list could push past the
  binder transaction limit and the host would show "Problem loading
  widget". A trailing day header stranded by the cut is dropped.

- Loosen the height cap so it only catches genuinely squashed widgets.
  It previously required ~320dp of height for LARGE, so widening a widget
  without also making it unusually tall — the exact resize #51 reports —
  stayed REGULAR or COMPACT and the feature was near a no-op for it.
  Thresholds now work on height minus header chrome.

- Lock the event stripe to the system font scale. It is a Dp beside sp
  text, so at large accessibility settings the text outgrew it and it
  under-ran the row it marks.

- Route the day-header and placeholder padding through the metrics table
  so vertical rhythm holds at the larger tiers, and derive the text
  indent from the row constants instead of a hardcoded 19dp.

- Share the bucketing as widget/WidgetScale.kt so MonthWidget (already
  SizeMode.Exact) can adopt one rule rather than growing a parallel copy.

- Anchor the type ramp to Material 3 type-scale roles per CLAUDE.md, with
  the two off-scale values marked and justified inline. COMPACT is
  unchanged, so a default-sized widget still looks exactly as before.

- Tie the "default size unchanged" test to the provider XML's declared
  3-cell band rather than one measured 222dp point.

- Hang the metrics off an ordinal-indexed table so lookup allocates
  nothing per recomposition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:47:37 +02:00
997296e21e Merge pull request 'fix: address code-review findings on the 2.16.0 branch' (!87) from fix/code-review-2.16.0 into release/v2.16.0 2026-07-20 11:52:20 +00:00
94355bf340 fix: address code-review findings on the 2.16.0 branch
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m43s
Correctness:
- Recurring writes: move the series DTSTART by the *wall-clock* shift applied
  to the edited occurrence and re-resolve it in the event's zone, instead of by
  a millisecond delta. The old delta baked in whichever UTC offset applied on
  the edited occurrence's date, so pinning a recurring event to another zone —
  or editing an occurrence on the far side of a DST boundary from the series
  anchor — shifted the whole series by an hour. Also snaps the anchor to a UTC
  midnight when the event becomes all-day.
- Detail card and edit form now resolve a pinned zone's abbreviation/offset at
  the *event's* instant, not at "now", so a July event no longer reads
  "CET · 10:00 AM" when opened in January.
- Agenda: the zone used to label multi-day rows now travels on
  AgendaUiState.Success rather than a process-lifetime file-level constant, so
  labelling can't disagree with the grouping after a device time-zone change.
- Week title: spell out the year when the week straddles New Year, via a new
  forceYear flag on formatCalendarTitle.

Performance:
- Build the ~600-entry zone catalogue off the main thread (produceState +
  Dispatchers.Default); resolve the device row's summary on its own so it still
  renders complete on the first frame.
- Pre-normalize each TimeZoneOption's search keys at construction, turning
  ~2400 NFD normalizations per keystroke into plain prefix/substring checks.
  Hoist the combining-mark Regex out of the hot path.
- Key the edit form's local-time line on the fields it reads instead of
  recomputing it on every keystroke.
- Move LauncherNameManager's PackageManager binder calls off the main thread.

Cleanup:
- Drop a duplicate Public icon import and the unused
  event_edit_timezone_clear string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:10:39 +02:00
722fc87259 fix(edit): honour week-start pref in recurrence picker + smooth weekday collapse
- The weekday toggles now order by the app's "Week starts on" setting
  (via WeekStartPref.resolveFirstDay), matching the month/week/agenda
  views, instead of always following the device locale. Threaded through
  a new EventEditViewModel.firstDayOfWeek flow.
- Carry the 16dp section gaps on each block rather than a parent
  spacedBy, so the weekday card's gap collapses together with the card
  under AnimatedVisibility — removing the end-of-animation jump in the
  ends section when switching away from Weekly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:04:57 +02:00
30fcbfa59f fix(widget): scale the agenda widget with its size (#51)
The "Upcoming" agenda widget used Glance's default SizeMode.Single: it was
composed once at the minimum size and the launcher stretched that single
RemoteViews when enlarged, so the text stayed small-widget-sized no matter how
big the widget grew. Reported as a "font size" request (#51), but it's really a
missing size-response.

Switch to SizeMode.Exact (like MonthWidget) and read LocalSize.current to pick
one of four tiers (COMPACT/REGULAR/LARGE/XLARGE), scaling type and row metrics.
Exact over Responsive so the ~30-day LazyColumn isn't replicated per tier.

Width picks the tier, height can only lower it. Width governs how much of a
title fits on a row, so it's what should drive type size; height only decides
how many rows are visible, so a tall narrow widget shows more events rather than
bigger text. Height does act as a cap, though, or a squashed widget would keep
the large type its width earned in a sliver of space. Thresholds are spread over
the width range a phone actually produces (measured on a Pixel/Nova: a compact
widget is 222dp wide, a large one 378dp) rather than a theoretical range, so the
tiers are reachable in practice; XLARGE is reserved for tablets/foldables.

COMPACT reproduces the original constants verbatim, so an existing widget is
visually unchanged. The tier logic lives in a pure, Glance-free AgendaScale.kt
(compose.ui.unit only) and is JVM-tested: the COMPACT baseline, the width
buckets, the height cap stepping a squashed widget down, and that height never
raises the tier.

No new setting: the widget follows the size the launcher/user already chose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:49:19 +02:00
c956ac79ed Merge pull request 'feat(settings): optional "Calendar" launcher name (#44)' (!86) from feat/app-name-toggle into release/v2.16.0
Reviewed-on: #86
2026-07-20 10:45:55 +00:00
7d357bad87 feat(settings): App name picker shows both names as preview cards
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m10s
Replace the single current-state hero + option rows with two selectable
launcher-mark cards (Calendula / Calendar), so the user sees what each choice
would look like, not just the current name. Tapping a card applies immediately
and highlights it (primary border + tinted container + check); the picker stays
open so the change is visible, and back exits — which also fixes the earlier
"hero doesn't update until reopened" gap.

Built on FullScreenPicker directly (the component OptionPicker wraps) to render
the custom card row; material-3 tokens throughout (surfaceContainerHigh /
primary / primaryContainer / outlineVariant, 24dp corners, 8dp-grid spacing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:09:10 +02:00
3ffe76412e feat(settings): launcher-mark hero on the App name picker
Add a preview hero to the App name picker header: the app's launcher mark over
the current name on a tonal surface card, the explanatory hint below it, then
generous space before the choices. Gives the picker the visual "display" the
inline hint lacked and separates the text from the options.

Follows the material-3 guidance — tonal surfaceContainerHigh card (no shadow),
28dp corner, 8dp-grid spacing, onSurface/onSurfaceVariant role pairing — and
reuses the onboarding BrandHero's squircle reconstruction of the adaptive icon
so it renders identically everywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:05:14 +02:00
91c4a84818 refactor(settings): app-name as a full-screen chooser, not a switch
Replace the inline App name switch with a GroupedRow that opens a full-screen
OptionPicker (Calendula / Calendar), matching the app's other "choose one"
settings and the ReFra pattern the user preferred. The row shows the current
name; the picker's header carries the explanatory + icon-may-move hint. Leaves
room for more launcher names later without redesigning the row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:57:28 +02:00
ccd433fee1 refactor(edit): redesign the custom recurrence picker (#42)
Rebuild the custom step of the recurrence picker so complex-but-supported
rules (e.g. "every 2 weeks on Mon+Tue") are discoverable and legible,
rather than buried in a ragged stack of mismatched controls.

- Add a live, human-readable summary of the rule as it is built, via the
  existing recurrenceText humanizer.
- Group interval + frequency into one tonal card; the frequency is now a
  SingleChoiceSegmentedButtonRow (all four units visible) instead of a
  dropdown.
- Reveal the weekday picks with AnimatedVisibility and house them, the
  "every" controls, and the ends group in consistent 16dp-inset grouped
  cards; the count field folds into the bottom of the ends run.

No behaviour, domain, or RRULE changes: parse/render logic and the set of
expressible rules are unchanged. The EXDATE half of #42 is deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:56:55 +02:00
03f6b7c8c1 feat(settings): optional "Calendar" launcher name (#44)
Add a Settings → Appearance toggle that switches the app's launcher label
between "Calendula" and "Calendar", for users on launchers that can't rename
apps themselves.

The launcher entry moves off MainActivity onto two <activity-alias> components
(DefaultNameAlias / CalendarNameAlias); exactly one is enabled at a time via
PackageManager.setComponentEnabledSetting. MainActivity keeps every other intent
filter; the android.app.shortcuts meta-data moves onto both aliases so the
long-press shortcut still publishes. Component-enabled state is the single source
of truth — no persisted preference.

The ComponentName uses the applicationId for the package (carrying the
.debug/.releasetest suffix) and the namespace for the class, since manifest
".Alias" names resolve against the namespace; switching to Calendar enables the
target alias before disabling the other to avoid a zero-entry launcher transient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:39:08 +02:00
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
5be5ff77d0 Merge pull request 'Release 2.15.0' (!79) from release/v2.15.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Successful in 13m40s
2026-07-15 18:54:02 +00:00
6519cbca79 release: cut 2.15.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 11m17s
Move the Unreleased changelog under [2.15.0] — 2026-07-15, bump
versionName/versionCode to 2.15.0/21500, and sync the F-Droid
per-version changelog.

Milestone 2.15.0 (all integrated): #21 #35 #36 #39 #40 #46 #52.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:34:52 +02:00
50510a23da feat(colors): optional raw calendar colours + readable title contrast
Add a "Soften calendar colours" setting (Settings → Design, default on).
Turning it off paints calendar and event colours raw, exactly as the sync
source (DAVx5/CalDAV) publishes them, instead of the theme-fitting pastels
(#36).

Event/calendar colours now flow through shared eventFill()/eventInk()
helpers gated by a LocalSoftenColors composition local (widgets read the
pref directly). Event titles pick black or white text by the fill's WCAG
relative luminance, so a dark colour stays legible whether softened or raw
(#21) — previously always near-black.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:26:34 +02:00
2279738371 docs(changelog): note the new French and Polish translations
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:42:19 +02:00
3d59962112 i18n: offer French and Polish in the language picker
Both landed as community translations through Weblate (French ~33%, Polish
~57%), above the bar already shipped for zh-CN. Add them to locales_config.xml —
the single source of truth for the in-app language picker and Android's per-app
language settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:40:10 +02:00
a96d8b6de9 Merge remote-tracking branch 'origin/main' into release/v2.15.0 2026-07-15 19:39:08 +02:00
94b9eeaa78 Merge pull request 'Translations update from Weblate' (!78) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #78
2026-07-15 17:38:51 +00:00
Bazyli Cyran
85fb091e22 Translated using Weblate (Polish)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m31s
Currently translated at 58.7% (252 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-15 17:34:15 +00:00
2139f8729c Merge remote-tracking branch 'origin/main' into release/v2.15.0 2026-07-15 19:28:22 +02:00
98a48aa795 docs(changelog): note the agenda date localization and gutter alignment fixes
Also correct the #35 entry — the empty-today marker is a card in the app but a
plain line in the widget, so call it a "note" rather than a card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:27:20 +02:00
d255d232b9 Merge pull request 'Translations update from Weblate' (!76) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #76
2026-07-15 17:24:26 +00:00
9999fcdd2a Merge pull request 'feat(detail): duplicate an event as a new one (#52)' (!77) from feat/duplicate-events into release/v2.15.0
Reviewed-on: #77
2026-07-15 17:23:44 +00:00
953ffdff97 fix(day): align the hour-gutter labels with the top bar hamburger
The day view's 48dp edge-to-edge hour gutter centred its labels at 24dp, 4dp
left of the hamburger. Give the gutter content the same 8dp start inset as the
week view so the labels centre on the hamburger (4dp bar inset + 24dp half icon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:19:42 +02:00
5377a2b466 fix(calendar): align the week-number gutter with the top bar hamburger
The month grid was inset 4dp while its weekday header (and the loading grid) used
8dp, so the week-number column — and the day cells under their labels — sat 4dp
left of where they should. Bring the grid to 8dp: the gutter centre now lands on
the hamburger (4dp bar inset + 24dp half icon), and day cells sit under their
weekday labels.

The week view's header badge and hour labels had the same drift (a 48dp edge-to-
edge gutter centres its content at 24dp, not 28dp). Its top section background
bleeds full-width when scrolled, so instead of insetting the whole content, give
just the gutter content an 8dp start inset to centre it on the hamburger too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:15:51 +02:00
e73148dc6c fix(widget): keep the empty-today line plain, not a card
The agenda widget's event rows are plain (a colour stripe + text, no card), so
the rounded "No more events today" surface looked out of place. Render it as a
muted line indented to the event titles instead, matching the widget's style.
The in-app agenda keeps its coffee-cup card, where event rows are cards too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:15:51 +02:00
a19772e3a7 docs(changelog): note the duplicate-event action (#52)
All checks were successful
Translations / check (pull_request) Successful in 26s
CI / ci (pull_request) Successful in 9m15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:11:34 +02:00
b3386eff43 Merge branch 'release/v2.15.0' into feat/duplicate-events 2026-07-15 19:10:29 +02:00
79e74d9995 feat(detail): duplicate an event as a new one (#52)
Add a Duplicate action to the event detail top bar that opens the shared
event form seeded with a copy of the event as a new, unsaved event, so a
non-recurring event can be re-created with just the day and time changed
instead of re-entering every field.

The copy reuses the existing prefilled-create overlay (createEvent), so it
becomes an independent event with the default reminder applied. Recurrence
is dropped — a duplicate is a single event; the edit form still exposes a
recurrence picker for anyone who wants a series. The occurrence's own times
carry over unchanged. Duplicate is offered for any loaded event, including
read-only ones (WebCal, birthdays): the source calendar is kept only when
it's writable, otherwise the copy resolves to the first writable calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:10:23 +02:00
396a5610aa fix(agenda): localize dates and refine the range bar
Localize every agenda date via a new localizedDateFormatter helper that lays a
field skeleton out in the locale's own order (Android best-pattern), instead of
a hardcoded day-month-year layout: the range-window summary, the screen's day
headers, and the widget's day headers. This also fixes the window mixing two
orders (e.g. "15 Jul – Aug 13, 2026").

Refine the range bar: the banner drops the range name (it already sits on the
selector button beside it) and shows just the concrete dates; the selector keeps
a subtle neutral surface tint — distinct from the top-bar view switcher's
secondary container so the two don't compete — and its right edge lines up with
the switcher. The anchored empty-today card takes a single event row's resting
corner radius.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:02:37 +02:00
b9329f6fb6 feat(agenda): anchor today with an empty-day card (#35)
Add an "Always show today" setting (Settings → Agenda, on by default) that
keeps today as the first entry in both the Agenda screen and its home-screen
widget even once nothing is left today. Under today's normal header a small
"No more events today" card appears — the coffee-cup empty-state motif in the
app, a rounded surface in the widget — so the first rows you see are clearly
today's rather than a future day's.

The anchor is a pure, JVM-tested helper (anchorTodayIfMissing) applied after
past-event filtering; in-app it only kicks in when the window starts on today,
never on a jumped-to date. The widget reads the pref reactively via per-instance
Glance state, mirroring the range/past-event settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:39:27 +02:00
1b731a4ab0 docs(changelog): note the ACTION_EDIT intent and .ics MIME types
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:25:56 +02:00
ab631365b2 feat(intent): handle ACTION_EDIT and broaden .ics MIME types
Round out the calendar-intent surface toward AOSP/Etar parity — the app
already handled VIEW (date + event), INSERT, and .ics open/share, but was
missing the edit action and the alternate .ics MIME labels.

- ACTION_EDIT on content://com.android.calendar/events/<id> now opens the
  event in the edit form (previously only VIEW → read-only detail existed).
  An assistant, task app, or widget can hand an event to Calendula to edit.
  A bare EDIT URI with no occurrence extras falls back to the event row's
  own DTSTART/DTEND, mirroring the #48 view-event fallback.
- ACTION_EDIT with no event id (AOSP's "edit a new event") maps to the same
  prefilled create form as ACTION_INSERT.
- The .ics VIEW/SEND filters now also accept text/x-vcalendar (vCalendar
  1.0 / .vcs) and application/ics — the alternate labels the same calendar
  data arrives under from some file/mail apps (matches Etar's ImportActivity).

Deliberately excluded: webcal:// / http(s) remote-calendar subscription
(needs INTERNET, which the app doesn't have) and the Google-web-link handler
(Google-specific + network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:23:40 +02:00
9aa370d583 Merge remote-tracking branch 'origin/main' into release/v2.15.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
2026-07-15 17:23:31 +02:00
iker Contreras
8cd75716ae Translated using Weblate (Spanish)
All checks were successful
Translations / check (pull_request) Successful in 27s
CI / ci (pull_request) Successful in 10m52s
Currently translated at 100.0% (429 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-07-14 02:41:02 +00:00
Thomas Tref
4feccf2008 Translated using Weblate (French)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m14s
Currently translated at 30.5% (131 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-13 23:05:35 +00:00
23e9a5a35b Merge pull request 'fix(reminders): show the day for reminders on another day (#46)' (!75) from fix/reminder-day-context into release/v2.15.0
Reviewed-on: #75
2026-07-13 15:35:03 +00:00
38a35be0f0 fix(reminders): show the day for reminders on another day (#46)
All checks were successful
CI / ci (pull_request) Successful in 5m31s
Translations / check (pull_request) Successful in 5s
A reminder fired ahead of an event on a different day showed only the
event's time (e.g. "09:30 – 10:00"), making it look like it was
happening today. reminderTimeText now prefixes timed events with a
relative day: "Tomorrow"/"Yesterday", the short weekday for another day
this week, or the exact date for anything further out.

The this-week boundary honours the user's "week starts on" setting: the
resolved first day of week is threaded through from ReminderNotifier, so
e.g. a Sunday reads as next week under a Sunday-start locale. All-day
events keep their explicit date (never ambiguous), and cross-midnight
timed events keep both explicit dates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:25:51 +02:00
f9f0572ec5 Merge pull request 'docs(changelog): add the custom snooze duration entry (#40)' (!74) from docs/changelog-snooze into release/v2.15.0
Reviewed-on: #74
2026-07-13 14:39:18 +00:00
0221972e6d docs(changelog): add the custom snooze duration entry (#40)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 12s
The custom snooze duration shipped in 3c97673 but had no changelog entry;
add it to the Unreleased section so it's in the 2.15.0 release notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:38:35 +02:00
86ea72d56f Merge pull request 'feat(edit): move an event to another calendar (#39)' (#73) from feat/change-event-calendar into release/v2.15.0 2026-07-13 14:30:07 +00:00
114db7939c ci(translations): run on every PR so the required check always reports
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 2m27s
The release/* branch protection requires the "Translations / check" status,
but the workflow was path-filtered to translation resources. A code-only PR
targeting a release branch never touches those, so the workflow never ran,
never posted its status, and the required check stayed pending forever —
permanently blocking the merge (only PRs that happened to change strings could
satisfy it).

Drop the path filter so it runs on every PR, mirroring the always-on `ci` job.
The parity check is SDK-free and passes when the committed translations are
consistent, so running it on unrelated PRs is effectively free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:27:10 +02:00
4503847c0d feat(edit): move an event to another calendar (#39)
All checks were successful
CI / ci (pull_request) Successful in 8m40s
The calendar row in the editor is now tappable when editing an existing
event: picking a different calendar moves the event there on save, rather
than forcing a delete-and-recreate.

CALENDAR_ID is sync-adapter-owned and can't be updated in place, so the
move is copy+delete: the master row is re-inserted on the target calendar
(preserving UID_2445 so backup dedup and sync identity survive), its
reminders and editable guests are copied, and — for a recurring series —
every exception is replayed against the new master (modified occurrences
via CONTENT_EXCEPTION_URI, cancellations as STATUS_CANCELED). The user's
field edits are then applied with the normal series update. Everything on
the new side is built before the source is deleted (post-before-delete),
with a rollback of the copy on any failure, so a move is all-or-nothing.

A calendar change forces whole-series scope, so it skips the recurring
scope dialog. Managed special-dates calendars stay locked. Colour is not
carried across (a raw/keyed colour may be invalid on the target account),
matching the existing calendar-switch behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:11:32 +02:00
60aa889c57 Merge pull request 'feat(settings): custom snooze duration, extract editor card to floret-kit' (!67) from feat/custom-snooze-duration into release/v2.15.0
Reviewed-on: #67
2026-07-08 16:10:32 +00:00
3c9767387b feat(settings): custom snooze duration, extract editor card to floret-kit
All checks were successful
CI / ci (pull_request) Successful in 10m14s
The Snooze-duration setting gains a Custom… option: a new single-select
SnoozeDurationPicker keeps the minute presets and adds a Custom row that
expands an amount field with a Minutes/Hours toggle, so any delay is settable
(not just the fixed presets). Closes the settings half of the snooze request.

The three presets-plus-custom editors (reminder default, agenda range, snooze)
now delegate to floret-kit's new CustomAmountEditor instead of each duplicating
the tonal editor card — the app keeps its domain math, strings and labels.

Re-pin the floret-kit submodule to the branch commit carrying CustomAmountEditor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:02:13 +02:00
78 changed files with 5870 additions and 655 deletions

View File

@@ -3,13 +3,15 @@ name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which # Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android # only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation). # build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
#
# Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is
# skipped on unrelated PRs and never posts its status, which leaves that
# required check pending forever and blocks the merge of any code-only PR into a
# release/* branch. The check itself is cheap and simply passes when the
# committed translations are consistent, so always running it costs nothing.
on: on:
pull_request: pull_request:
paths:
- 'app/src/main/res/values*/strings.xml'
- 'app/src/main/res/xml/locales_config.xml'
- 'scripts/check_translations.py'
- '.gitea/workflows/translations.yaml'
concurrency: concurrency:
group: translations-${{ github.ref }} group: translations-${{ github.ref }}

View File

@@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed
- The "Upcoming" agenda widget now scales its text and rows to the size you give
it. Previously it was laid out once for the smallest size and simply stretched
when enlarged, so the text stayed small no matter how big you made the widget.
Now a bigger widget gets bigger, more readable type and roomier rows, while the
default size looks exactly as before — no new setting; it follows the size you
already chose ([#51]).
## [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
- Show raw calendar colours. Calendula normally softens each calendar and event
colour toward a theme-fitting pastel so harsh sync colours read well on both
light and dark; a new **Soften calendar colours** setting (Settings → Design,
on by default) lets you turn that off and paint the exact colours your calendar
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
@leonp5 for the report ([#36]).
- Readable titles on dark event colours. An event bar's title now shows in white
on a dark colour and near-black on a light one, chosen automatically from the
colour's brightness, so a deep blue or purple event is legible at a glance in
the busy Week and Month views instead of dark-on-dark. This applies whether or
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
- A custom snooze duration. The **Snooze duration** setting (Settings →
Notifications) gains a **Custom…** option next to the minute presets: pick any
amount and switch between minutes and hours, so a snoozed reminder comes back
after exactly the delay you want instead of only a preset one ([#40]).
- Move an event to another calendar. When editing an existing event, the
calendar row is now tappable — pick a different calendar and saving moves the
event across, instead of having to delete it and recreate it elsewhere.
Recurring series move as a whole, keeping their individually-edited and
cancelled occurrences, and any reminders and guests come along too. A calendar
can't simply be reassigned underneath an event, so Calendula recreates it on
the target and removes the original — the same approach other calendar apps
take. Thanks to @prismplex for the suggestion ([#39]).
- Open an event straight into the edit form from another app. Calendula already
answered the "new event" and "open this event" hand-offs from other apps and
widgets; it now also answers the "edit this event" one, so an assistant, task
app, or widget can send an existing event to Calendula and land on its edit
screen rather than the read-only details. A hand-off with no event attached
opens the same prefilled create form as "new event". Calendula also recognises
a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too.
- Keep today at the top of the agenda. A new **Always show today** setting
(Settings → Agenda, on by default) anchors today as the first entry in both the
Agenda screen and its home-screen widget even once nothing is left today —
under today's header a "No more events today" note appears — so the first
events you see are clearly today's rather than a future day's. Turn it off to
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
- Duplicate an event. The event details now carry a **Duplicate** action that
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
a one-off like a shift or an appointment can be recreated by just changing the
day and time instead of re-typing every field. The copy keeps the original's
time, and its title, location, notes, colour, guests and reminders come along;
it's saved as its own single event (any repeat is left off — add one in the
editor if you want it). Duplicate works from read-only calendars too, dropping
the copy into a writable one. Thanks to @internet-rando for the suggestion
([#52]).
- French and Polish, in early form. Calendula has started speaking French and
Polish, both contributed as community translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are partway there, so untranslated parts still show in English until they
fill out — you can already pick either under Settings → Language or in Android's
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
(Polish) for getting them started; help finishing them is very welcome.
### Fixed
- Reminders for events on another day no longer read as if they were today. A
reminder fired ahead of time — say, the day before — used to show only the
event's time, making it look like it was happening now. The notification now
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
week, or the date for anything further out. Thanks to @moonj for the report
([#46]).
- Agenda dates now read in your locale's format. The agenda's range bar and its
day headers used a fixed day-month-year layout — and the range span even mixed
two orders (e.g. "15 Jul Aug 13, 2026") — instead of following your language's
conventions. Dates across the agenda and its widget now use your locale's own
field order, matching the rest of the app. The range bar also no longer repeats
the range's name from the selector button beside it, showing just the dates.
- Calendar gutters line up with the menu button. The Month view's week-number
column, and the Week and Day views' hour labels, sat a few pixels left of the
hamburger menu above them; they now line up with it. In Month view the day
cells also sit squarely under their weekday letters.
## [2.14.1] — 2026-07-13 ## [2.14.1] — 2026-07-13
### Fixed ### Fixed
@@ -908,11 +1040,22 @@ automatically, with zero telemetry and no internet permission.
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25 [#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27 [#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32 [#32]: https://codeberg.org/jlmakiola/calendula/issues/32
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#47]: https://codeberg.org/jlmakiola/calendula/issues/47
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
[#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 + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21401 versionCode = 21600
versionName = "2.14.1" versionName = "2.16.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

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

View File

@@ -63,10 +63,9 @@
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
<action android:name="android.intent.action.MAIN" /> the two <activity-alias> below, so the app name can be switched at
<category android:name="android.intent.category.LAUNCHER" /> runtime (issue #44). MainActivity keeps every other filter. -->
</intent-filter>
<!-- Be selectable as the system calendar app. Android has no API for <!-- Be selectable as the system calendar app. Android has no API for
an app to make itself the default, so registering the filters an app to make itself the default, so registering the filters
@@ -97,32 +96,58 @@
<data android:mimeType="time/epoch" /> <data android:mimeType="time/epoch" />
</intent-filter> </intent-filter>
<!-- Open a .ics file (file manager / email attachment / browser). --> <!-- Open a .ics/.vcs file (file manager / email attachment / browser).
The three MIME types cover the common labels the same calendar
data arrives under: iCalendar 2.0 (text/calendar), the older
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
mail apps emit — Android cross-products the scheme and mimeType
tags, so each MIME is accepted on both schemes (matches Etar). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:mimeType="text/calendar" /> <data android:scheme="content" />
<data android:scheme="file" android:mimeType="text/calendar" /> <data android:scheme="file" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter> </intent-filter>
<!-- Receive a .ics shared from another app. --> <!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/calendar" /> <data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter> </intent-filter>
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us <!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it: to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events dir mime type, carrying the new ACTION_INSERT on the events dir mime type, carrying the new
event's fields as CalendarContract extras event's fields as CalendarContract extras
(MainActivity.insertFormOrNull, issue #30). --> (MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
to the same prefilled create form. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.INSERT" /> <action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/event" /> <data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter> </intent-filter>
<!-- Edit an existing event another app/assistant/widget points at:
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
AOSP fires it. Opens the occurrence in the edit form (not the
read-only detail — that's the VIEW filter above). Matched by the
provider's item MIME type, like the VIEW filter. The occurrence's
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
supplied (MainActivity.editEventKeyOrNull). -->
<intent-filter>
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<!-- Open an existing event another app/widget points at (e.g. tapping <!-- Open an existing event another app/widget points at (e.g. tapping
an event in the Todo Agenda widget): ACTION_VIEW on an event in the Todo Agenda widget): ACTION_VIEW on
content://com.android.calendar/events/<id>, the way AOSP fires it. content://com.android.calendar/events/<id>, the way AOSP fires it.
@@ -139,11 +164,47 @@
<data android:mimeType="vnd.android.cursor.item/event" /> <data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter> </intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). --> </activity>
<!-- Launcher entry for MainActivity, split into two aliases so the app's
launcher name can be switched at runtime between "Calendula" and
"Calendar" (issue #44). Exactly one is enabled at a time; the app
flips them via PackageManager.setComponentEnabledSetting
(LauncherNameManager). The shortcuts meta-data lives here, not on
MainActivity, because static shortcuts are published by whichever
component owns the MAIN/LAUNCHER filter. -->
<activity-alias
android:name=".DefaultNameAlias"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity-alias>
<activity-alias
android:name=".CalendarNameAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_calendar_alias"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<!-- Standalone surface for a captured crash report. MainActivity routes <!-- Standalone surface for a captured crash report. MainActivity routes
here on a startup crash-loop, so it stays clear of the app's Hilt here on a startup crash-loop, so it stays clear of the app's Hilt

View File

@@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -70,6 +71,11 @@ class MainActivity : AppCompatActivity() {
// CalendarHost, which opens it in the create form for review. // CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null) private var requestedInsertForm by mutableStateOf<EventForm?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog // A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup // over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below. // crash-loop is handled out of band, before setContent — see below.
@@ -95,6 +101,7 @@ class MainActivity : AppCompatActivity() {
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull() requestedInsertForm = intent.insertFormOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
@@ -134,6 +141,7 @@ class MainActivity : AppCompatActivity() {
CompositionLocalProvider( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines, LocalShowHourLines provides settings.showHourLines,
LocalSoftenColors provides settings.softenColors,
) { ) {
RootScreen( RootScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -145,6 +153,8 @@ class MainActivity : AppCompatActivity() {
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm, requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null }, onInsertConsumed = { requestedInsertForm = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
) )
} }
// A persistent corner marker so a debug build is never // A persistent corner marker so a debug build is never
@@ -183,6 +193,7 @@ class MainActivity : AppCompatActivity() {
intent.navRequestOrNull()?.let { requestedNav = it } intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it } intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it } intent.insertFormOrNull()?.let { requestedInsertForm = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
} }
/** /**
@@ -203,13 +214,19 @@ class MainActivity : AppCompatActivity() {
} }
/** /**
* A prefilled new-event form from an external `ACTION_INSERT` launch — another * A prefilled new-event form from an external launch asking us to create an
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30). * event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
* The new event's fields ride as CalendarContract extras; anything omitted * (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
* falls back to the in-app "new event" defaults in [buildInsertEventForm]. * event", i.e. create). The new event's fields ride as CalendarContract
* extras; anything omitted falls back to the in-app "new event" defaults in
* [buildInsertEventForm].
*/ */
private fun Intent.insertFormOrNull(): EventForm? { private fun Intent.insertFormOrNull(): EventForm? {
if (action != Intent.ACTION_INSERT) return null // ACTION_EDIT on an existing event routes to the edit form instead
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
val isCreate = action == Intent.ACTION_INSERT ||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
if (!isCreate) return null
return buildInsertEventForm( return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME), beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME), endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
@@ -318,6 +335,31 @@ class MainActivity : AppCompatActivity() {
) )
} }
/**
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
* an assistant, task app, or widget that wants to open the event for editing
* rather than viewing). Opens it in the edit form. Reuses the same
* occurrence-key channel as reminder/view taps; the caller passes the
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
* [EventEditViewModel.openForEdit] falls back to the event row's own
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
*/
private fun Intent.editEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_EDIT) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "events") return null
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
return longArrayOf(
eventId,
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
)
}
companion object { companion object {
// The calendar provider's authority/host. A date tap arrives as // The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>. // ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.

View File

@@ -0,0 +1,109 @@
package de.jeanlucmakiola.calendula.data.appname
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/** The launcher label the app shows for itself (issue #44). */
enum class LauncherName { CALENDULA, CALENDAR }
/** The two launcher aliases declared in the manifest. */
enum class LauncherAlias { DEFAULT, CALENDAR }
/** One component-enable change in a [aliasWritePlan]. */
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
/**
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
* manifest default, "Calendula".
*/
fun launcherNameFor(calendarAliasState: Int): LauncherName =
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
LauncherName.CALENDAR
} else {
LauncherName.CALENDULA
}
/**
* The ordered enable/disable steps to switch the launcher name to [target].
* Always **enables the target alias first, then disables the other** — the two
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
* only transient the launcher can observe is a harmless two-entry state, never a
* zero-entry one (which would briefly drop the app from the launcher).
*/
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
LauncherName.CALENDAR -> listOf(
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
)
LauncherName.CALENDULA -> listOf(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
)
}
/**
* Switches the app's launcher label between "Calendula" and "Calendar" by
* enabling/disabling the two `<activity-alias>` components (issue #44). The
* component-enabled state is the single source of truth — there is no persisted
* preference — so [current] reads it straight from [PackageManager] and the two
* always agree.
*
* The decision logic lives in the pure [launcherNameFor] / [aliasWritePlan]
* functions above (JVM-tested); this class is only the thin framework seam.
*/
@Singleton
class LauncherNameManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val packageManager: PackageManager get() = context.packageManager
// The class portion is namespace-qualified (suffix-free), the package portion
// is the applicationId (which carries the .debug / .releasetest suffix). The
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
// the real component is <namespace>.CalendarNameAlias registered under the
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
// — its leading-dot form prepends the applicationId to the class too, producing
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
private fun component(alias: LauncherAlias): ComponentName {
val simpleName = when (alias) {
LauncherAlias.DEFAULT -> "DefaultNameAlias"
LauncherAlias.CALENDAR -> "CalendarNameAlias"
}
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
}
/** The launcher name currently in effect. */
fun current(): LauncherName =
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
/** Switch the launcher name to [name] (no-op cost if already active). */
fun set(name: LauncherName) {
for (change in aliasWritePlan(name)) {
val state = if (change.enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
packageManager.setComponentEnabledSetting(
component(change.alias),
state,
PackageManager.DONT_KILL_APP,
)
}
}
private companion object {
/**
* The app's namespace (R-class package) — NOT `applicationId`, which
* carries the build-type suffix. Kept in sync with `namespace` in
* `app/build.gradle.kts`.
*/
const val NAMESPACE = "de.jeanlucmakiola.calendula"
}
}

View File

@@ -18,6 +18,8 @@ import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
@@ -179,6 +181,25 @@ interface CalendarDataSource {
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
) )
/**
* Move an event (for recurring events: the whole series, with its modified
* and cancelled occurrences) to [targetCalendarId], returning the new
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
* place, so this is copy+delete: the row is re-inserted on the target (its
* `UID_2445` preserved), its exceptions, reminders and editable guests
* replayed, the [original]→[updated] field edits applied, then the source
* deleted. The source is removed only once the copy fully succeeds; a failure
* before that rolls the new event back, leaving the original untouched.
* [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/** /**
* Change a single occurrence of a recurring event by inserting a * Change a single occurrence of a recurring event by inserting a
* modified-occurrence exception at [beginMillis] (the occurrence's * modified-occurrence exception at [beginMillis] (the occurrence's
@@ -885,6 +906,190 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// CALENDAR_ID can't be updated in place, so re-create the event on the
// target calendar and delete the source. Everything that identifies or
// hangs off the event is copied first; the source row is removed only
// once the copy (including its exceptions) is complete — a failure
// anywhere before that rolls the new event back, so the move is
// all-or-nothing and never leaves a half-copied duplicate.
val master = queryMoveMaster(eventId)
?: throw WriteFailedException("read event to move id=$eventId")
// Keep the source UID so an .ics backup dedups and a sync adapter can
// recognise the moved event; mint one only if the source carried none.
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
val newEventId = resolver.insert(
CalendarContract.Events.CONTENT_URI,
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
)?.let(ContentUris::parseId)
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
try {
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
insertAttendees(newEventId, editableAttendees(eventId))
copyExceptions(fromEventId = eventId, toEventId = newEventId)
// Apply the user's field edits exactly as a same-calendar "all
// events" save would: the moved master carries the source's values,
// so the dirty diff against [original] writes only what changed
// (including a time or rrule edit made in the same save).
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
} catch (t: Throwable) {
// Undo the partial copy so a failed move leaves nothing behind; the
// source is still intact (it's deleted only past this point).
runCatching { deleteEvent(newEventId) }
throw t
}
deleteEvent(eventId)
return newEventId
}
/** The master row of the event to move, as a verbatim-insert snapshot. */
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
MoveMasterProjection.COLUMNS,
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
MoveMaster(
uid = r.getString(MoveMasterProjection.IDX_UID),
event = MasterEventSnapshot(
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
duration = r.getString(MoveMasterProjection.IDX_DURATION),
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
status = r.getInt(MoveMasterProjection.IDX_STATUS)
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
location = r.getString(MoveMasterProjection.IDX_LOCATION),
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
),
)
}
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
CalendarContract.Reminders.CONTENT_URI,
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(fromEventId.toString()),
null,
)?.use { c ->
while (c.moveToNext()) {
val values = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, toEventId)
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
put(CalendarContract.Reminders.METHOD, c.getInt(1))
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
}
}
} ?: Unit
/**
* The event's editable guests as [EventAttendee]s — the rows the move
* re-creates. Mirrors the edit form's filter: the organizer and resource
* rows (backend-owned, not user-editable) and any without an email are
* dropped; the response status resets to "invited" on re-insert.
*/
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
.filter {
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
}
.mapNotNull { a ->
a.email?.takeIf { it.isNotBlank() }?.let { email ->
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
}
}
/**
* Replay every exception of the series [fromEventId] against the moved series
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
* exception, a modified one is re-inserted with its fields (then its reminders
* reconciled and editable guests copied). No-op for a non-recurring event.
*/
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
queryExceptionRows(fromEventId).forEach { ex ->
if (ex.isCancelled) {
val values = ContentValues().apply {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
resolver.insert(
ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
),
values,
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
} else {
val uri = resolver.insert(
ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
),
buildCopiedExceptionValues(ex).toContentValues(),
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
val newExceptionId = ContentUris.parseId(uri)
// The provider may clone the parent's reminders onto the new
// exception; reconcile to the source exception's exact set so
// they neither double nor drop. Same DTSTART → same all-day
// encoding, so the stored offsets match directly.
reconcileReminders(
newExceptionId,
queryReminders(ex.exceptionEventId).map { it.minutes },
)
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
}
}
}
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
CalendarContract.Events.CONTENT_URI,
ExceptionProjection.COLUMNS,
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
arrayOf(seriesEventId.toString()),
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
)?.use { c ->
c.mapAll {
val r = CursorColumnReader(c)
val status = r.getInt(ExceptionProjection.IDX_STATUS)
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
ExceptionRowSnapshot(
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
status = status,
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
duration = r.getString(ExceptionProjection.IDX_DURATION),
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
location = r.getString(ExceptionProjection.IDX_LOCATION),
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
)
}
} ?: emptyList()
override fun updateOccurrence( override fun updateOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,

View File

@@ -61,6 +61,20 @@ interface CalendarRepository {
*/ */
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
/**
* Move an event (recurring: the whole series, with its exceptions) to
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
* the new event's `Events._ID`. Copy+delete under the hood
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
* place.
*/
suspend fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long
/** /**
* Change a single occurrence of a recurring event (exception row with the * Change a single occurrence of a recurring event (exception row with the
* form's values); returns the exception's `Events._ID`. * form's values); returns the exception's `Events._ID`.

View File

@@ -157,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.deleteEvent(eventId) dataSource.deleteEvent(eventId)
} }
override suspend fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
)
}
override suspend fun updateOccurrence( override suspend fun updateOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,

View File

@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toJavaLocalDateTime
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.LocalDateTime as JavaLocalDateTime
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
internal data class EventWriteTimes( internal data class EventWriteTimes(
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
/** /**
* All-day events live at UTC midnights with an exclusive DTEND (the * All-day events live at UTC midnights with an exclusive DTEND (the
* CalendarContract convention — a one-day event ends at the next midnight); * CalendarContract convention — a one-day event ends at the next midnight);
* timed events resolve their wall-clock values in [zone]. * timed events resolve their wall-clock values in the form's own
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
* one. Passing the device zone is therefore still correct for an unpinned form —
* but it no longer overrides a pinned event's zone, which is what used to
* silently re-anchor a foreign-zone event to the device on any time edit.
*/ */
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) { internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
EventWriteTimes( EventWriteTimes(
@@ -31,22 +37,56 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
timezone = "UTC", timezone = "UTC",
) )
} else { } else {
val writeZone = writeZone(zone)
EventWriteTimes( EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
timezone = zone.id, timezone = writeZone.id,
) )
} }
/**
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
* provider's date anchor), otherwise the form's own pinned zone, falling back to
* [deviceZone] when it doesn't pin one or pins something the tz database can't
* parse.
*/
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
ZoneOffset.UTC
} else {
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
}
/**
* The form's start as a bare wall-clock value — what the user sees on the form,
* stripped of any zone. All-day events use their date's midnight rather than
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
* event back to timed has something to show.
*/
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
start.date.toJavaLocalDate().atStartOfDay()
} else {
start.toJavaLocalDateTime()
}
/** /**
* RFC 2445 duration for a recurring event's row (the provider requires * RFC 2445 duration for a recurring event's row (the provider requires
* DURATION instead of DTEND when an RRULE is set): whole days for all-day * DURATION instead of DTEND when an RRULE is set): whole days for all-day
* events, seconds otherwise. * events, seconds otherwise.
*/ */
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) { internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D" rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
/**
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
* events (the provider's convention), seconds otherwise. Shared by the write
* paths that need a DURATION but start from a raw millisecond span (the series
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
*/
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
"P${spanMillis / MILLIS_PER_DAY}D"
} else { } else {
"P${(dtEndMillis - dtStartMillis) / 1_000L}S" "P${spanMillis / 1_000L}S"
} }
/** /**
@@ -95,10 +135,12 @@ internal fun buildEventInsertValues(
* Time fields travel together (the provider validates them as a unit): * Time fields travel together (the provider validates them as a unit):
* - unchanged times, all-day flag and rrule → no time columns at all; * - unchanged times, all-day flag and rrule → no time columns at all;
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
* - recurring result → the *series* DTSTART moves by the same delta the user * - recurring result → the *series* DTSTART moves by the same **wall-clock**
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's * shift the user applied to the displayed occurrence and is re-resolved in the
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
* past occurrences intact when someone edits a later occurrence's time. * replaces DTEND, RRULE is written. This keeps past occurrences intact when
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
* stable across a DST boundary or a zone change between the two.
*/ */
internal fun buildEventUpdateValues( internal fun buildEventUpdateValues(
original: EventForm, original: EventForm,
@@ -125,10 +167,14 @@ internal fun buildEventUpdateValues(
putAll(eventColorColumns(updated.colorKey, updated.color)) putAll(eventColorColumns(updated.colorKey, updated.color))
} }
// A zone change counts as a time change even when the wall-clock is
// untouched: the same 09:00 in another zone is a different instant, so
// DTSTART has to move with it.
val timesChanged = updated.start != original.start || val timesChanged = updated.start != original.start ||
updated.end != original.end || updated.end != original.end ||
updated.isAllDay != original.isAllDay || updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule updated.rrule != original.rrule ||
updated.timezone != original.timezone
if (!timesChanged) return@buildMap if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone) val newTimes = updated.toWriteTimes(zone)
@@ -140,8 +186,23 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.RRULE, null)
put(CalendarContract.Events.DURATION, null) put(CalendarContract.Events.DURATION, null)
} else { } else {
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis // Move the series anchor by the *wall-clock* shift the user applied to the
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta) // displayed occurrence, then re-resolve it in the event's (possibly new)
// zone — never by a millisecond delta. An instant delta silently bakes in
// the offset that happened to apply on the edited occurrence's date, which
// is a different offset from the series anchor's whenever a DST boundary
// sits between them, or whenever the zone itself changed. Working in wall
// clock keeps "09:00" meaning 09:00 at both ends.
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
.atZone(original.writeZone(zone)).toLocalDateTime()
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
val shifted = seriesLocal.plus(wallClockShift)
// An all-day series anchor must sit on a UTC midnight. A pure day move
// already lands there (both ends are midnights), but *switching* a
// recurring event to all-day shifts by a time-of-day too, so snap.
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule) put(CalendarContract.Events.RRULE, updated.rrule)
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
@@ -182,6 +243,121 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color)) putAll(eventColorColumns(form.colorKey, form.color))
} }
/**
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
* account, so the moved copy inherits the target calendar's colour instead.
*/
internal data class MasterEventSnapshot(
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long,
val dtEndMillis: Long?,
val duration: String?,
val rrule: String?,
val rdate: String?,
val exdate: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val status: Int?,
val location: String?,
val description: String?,
)
/**
* Column values re-creating [snapshot] as a fresh Events row on
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
* RDATE/EXDATE) so the series' generated instances — and therefore the
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
* caller layers the user's field edits on top with a normal series update.
*/
internal fun buildMovedMasterValues(
snapshot: MasterEventSnapshot,
targetCalendarId: Long,
uid: String,
): Map<String, Any?> = buildMap {
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
if (snapshot.rrule != null) {
put(CalendarContract.Events.RRULE, snapshot.rrule)
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
} else {
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
}
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
}
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
/**
* Raw provider snapshot of one exception row of a recurring series (a modified
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
*/
internal data class ExceptionRowSnapshot(
val exceptionEventId: Long,
val originalInstanceMillis: Long,
val isCancelled: Boolean,
val status: Int?,
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long,
val dtEndMillis: Long?,
val duration: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val location: String?,
val description: String?,
)
/**
* Column values replaying a *modified* occurrence [snapshot] against the moved
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
* length travels as DURATION (the provider rejects DTEND on an exception). A
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
* STATUS_CANCELED) — this builder is only for the modified case.
*/
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(
CalendarContract.Events.DURATION,
snapshot.duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration(
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
snapshot.isAllDay,
),
)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
}
/** /**
* Column values for a *cancelled*-occurrence exception row ("delete only this * Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the * event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the

View File

@@ -190,6 +190,86 @@ internal object SearchProjection {
const val IDX_RDATE = 11 const val IDX_RDATE = 11
} }
/**
* The master/one-off Events row of an event about to be moved to another
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
* generates the same instances, and `UID_2445` so identity survives the move.
*/
internal object MoveMasterProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events.UID_2445,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE,
CalendarContract.Events.EXDATE,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.STATUS,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
)
const val IDX_UID = 0
const val IDX_TITLE = 1
const val IDX_DTSTART = 2
const val IDX_DTEND = 3
const val IDX_DURATION = 4
const val IDX_RRULE = 5
const val IDX_RDATE = 6
const val IDX_EXDATE = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_ALL_DAY = 9
const val IDX_AVAILABILITY = 10
const val IDX_ACCESS_LEVEL = 11
const val IDX_STATUS = 12
const val IDX_LOCATION = 13
const val IDX_DESCRIPTION = 14
}
/**
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
* `DELETED = 0` so provider tombstones aren't replayed.
*/
internal object ExceptionProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
CalendarContract.Events.STATUS,
CalendarContract.Events.TITLE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
)
const val IDX_ID = 0
const val IDX_ORIGINAL_INSTANCE_TIME = 1
const val IDX_STATUS = 2
const val IDX_TITLE = 3
const val IDX_ALL_DAY = 4
const val IDX_DTSTART = 5
const val IDX_DTEND = 6
const val IDX_DURATION = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_AVAILABILITY = 9
const val IDX_ACCESS_LEVEL = 10
const val IDX_LOCATION = 11
const val IDX_DESCRIPTION = 12
}
internal object AttendeeProjection { internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf( val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME, CalendarContract.Attendees.ATTENDEE_NAME,

View File

@@ -22,8 +22,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import java.time.temporal.WeekFields import java.time.temporal.WeekFields
import java.util.Locale import java.util.Locale
@@ -81,6 +86,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value) WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
} }
/**
* The resolved first day of the week as a hot [StateFlow] — the one shape every
* screen that orders or lays out weekdays should use (the month grid, the agenda
* "this week" range, the recurrence weekday toggles). Sharing it keeps the
* initial-frame value identical everywhere; hand-rolled copies had drifted onto
* different `initialValue`s, so two surfaces could disagree for a frame.
*/
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
weekStart
.map { it.resolveFirstDay(Locale.getDefault()) }
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5_000L),
// Seed with the locale convention rather than a hardcoded weekday, so
// the first frame is already right for everyone still on Auto.
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
)
/** /**
* Display settings (M4) persisted app-side: theme override, Material You * Display settings (M4) persisted app-side: theme override, Material You
* dynamic colour, and week start. Language is handled separately through * dynamic colour, and week start. Language is handled separately through
@@ -114,6 +137,21 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DYNAMIC_COLOR_KEY] = enabled } store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
} }
/**
* Whether raw provider colours are softened toward theme-fitting pastels
* before display (issue #36). Defaults to ON — the historical look, which
* caps harsh sync colours and pins brightness so entries read on both
* themes. Turning it off paints the calendar/event colour exactly as the
* sync source (DAVx5/CalDAV) publishes it.
*/
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
prefs[SOFTEN_COLORS_KEY] ?: true
}
suspend fun setSoftenCalendarColors(enabled: Boolean) {
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
}
/** /**
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque * Custom-font tokens per Material typeface role (issue #19). Stored as opaque
* strings — "system", "custom", or a bundled font's token — resolved to a * strings — "system", "custom", or a bundled font's token — resolved to a
@@ -218,6 +256,21 @@ class SettingsPrefs @Inject constructor(
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled } 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 * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the * [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -252,6 +305,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled } store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
} }
/**
* Whether the agenda (both the in-app screen and the home-screen widget)
* always anchors today at the top with a "nothing left today" placeholder,
* even once today has no remaining events (issue #35). Default ON — makes
* today's events easy to tell apart from a future day's at a glance.
*/
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
}
suspend fun setAgendaShowToday(enabled: Boolean) {
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
}
/** /**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change * the historical hard-coded startup view — so existing users see no change
@@ -325,6 +392,26 @@ class SettingsPrefs @Inject constructor(
} }
} }
/**
* Zones the user picked recently, most recent first — the timezone picker's
* default list, since scrolling ~600 zones to re-find the two you actually
* use is the whole problem. Stored comma-joined (IANA ids never contain a
* comma); unparseable ids are dropped on read, so a zone the tz database
* later retires can't wedge the list.
*/
val recentTimeZones: Flow<List<String>> = store.data.map { prefs ->
parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])
}
suspend fun addRecentTimeZone(zoneId: String) {
store.edit { prefs ->
val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]))
.distinct()
.take(MAX_RECENT_TIME_ZONES)
prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",")
}
}
/** /**
* Whether opening the new-event form focuses the title field and raises the * Whether opening the new-event form focuses the title field and raises the
* keyboard straight away (issue #10). Default ON — a new event almost always * keyboard straight away (issue #10). Default ON — a new event almost always
@@ -621,6 +708,13 @@ class SettingsPrefs @Inject constructor(
private fun titleTemplateKey(type: SpecialDateType) = private fun titleTemplateKey(type: SpecialDateType) =
stringPreferencesKey("special_dates_title_${type.name}") stringPreferencesKey("special_dates_title_${type.name}")
private fun parseRecentTimeZones(stored: String?): List<String> =
stored?.split(',').orEmpty()
.map { it.trim() }
.filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess }
.distinct()
.take(MAX_RECENT_TIME_ZONES)
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) { private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS null -> DEFAULT_FORM_FIELDS
else -> stored.split(',') else -> stored.split(',')
@@ -665,6 +759,7 @@ class SettingsPrefs @Inject constructor(
companion object { companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font") internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font") internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp") internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
@@ -673,11 +768,14 @@ class SettingsPrefs @Inject constructor(
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar") internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val AGENDA_SHOW_TODAY_KEY =
booleanPreferencesKey("agenda_show_today")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") 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 DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
@@ -704,6 +802,10 @@ class SettingsPrefs @Inject constructor(
stringPreferencesKey("per_calendar_allday_reminder_override") stringPreferencesKey("per_calendar_allday_reminder_override")
internal val DEFAULT_FORM_FIELDS = internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description) setOf(EventFormField.Location, EventFormField.Description)
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
internal const val MAX_RECENT_TIME_ZONES = 5
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled") internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")

View File

@@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor(
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first() val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) .is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val zone = ZoneId.systemDefault()
val locale = Locale.getDefault()
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
// its shared ISO number (1..7) for the date math in reminderTimeText.
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
val time = reminderTimeText( val time = reminderTimeText(
beginMillis = alert.beginMillis, beginMillis = alert.beginMillis,
endMillis = alert.endMillis, endMillis = alert.endMillis,
isAllDay = alert.isAllDay, isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(), zone = zone,
locale = Locale.getDefault(), locale = locale,
is24Hour = is24Hour, is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
) )
val text = listOfNotNull(time, alert.location).joinToString(" · ") val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)

View File

@@ -1,23 +1,37 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import java.time.DayOfWeek
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale import java.util.Locale
/** /**
* The one line of time context in a reminder notification. Pure so it can be * The one line of time context in a reminder notification. Pure so it can be
* JVM-tested: * JVM-tested.
* *
* - timed, same day: "09:30 10:00" * Timed events that fall on a day other than [today] are prefixed with that
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time) * day, so a reminder fired ahead of time no longer reads as if the event were
* today (issue #46). The prefix prefers natural language and stays short:
*
* - today: "09:30 10:00" (no prefix)
* - tomorrow / yesterday: "Tomorrow, 09:30 10:00" ([tomorrowLabel] / [yesterdayLabel])
* - elsewhere this week: "Thu, 09:30 10:00" (localized short weekday)
* - further out: "16 Jul, 09:30 10:00" (medium date — a weekday
* alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time,
* already unambiguous)
* - all-day, one day: "11 Jun 2026" * - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 12 Jun 2026" * - all-day, multi-day: "11 Jun 2026 12 Jun 2026"
* *
* All-day instances store UTC midnights with an exclusive end, so they are * All-day instances already carry an explicit date, so they never gain a
* relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day. * read in UTC and the end day is the last *covered* day.
*/ */
fun reminderTimeText( fun reminderTimeText(
@@ -27,6 +41,10 @@ fun reminderTimeText(
zone: ZoneId, zone: ZoneId,
locale: Locale, locale: Locale,
is24Hour: Boolean, is24Hour: Boolean,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
tomorrowLabel: String,
yesterdayLabel: String,
): String { ): String {
if (isAllDay) { if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -43,18 +61,70 @@ fun reminderTimeText(
} }
val timeFormat = timeOfDayFormatter(is24Hour, locale) val timeFormat = timeOfDayFormatter(is24Hour, locale)
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone) val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
val end = Instant.ofEpochMilli(endMillis).atZone(zone) val end = Instant.ofEpochMilli(endMillis).atZone(zone)
return if (begin.toLocalDate() == end.toLocalDate()) { return if (begin.toLocalDate() == end.toLocalDate()) {
timeFormat.format(begin) + RANGE + timeFormat.format(end) val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
val prefix = relativeDayPrefix(
day = begin.toLocalDate(),
today = today,
firstDayOfWeek = firstDayOfWeek,
locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
)
if (prefix == null) range else "$prefix, $range"
} else { } else {
// Cross-day: medium date + the chosen short time, joined per side. Built // Cross-day: medium date + the chosen short time, joined per side. Built
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice // from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
// applies to the time portion too. // applies to the time portion too. The explicit dates already say which
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) // day, so no relative prefix is layered on top.
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" } val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
dateTime(begin) + RANGE + dateTime(end) dateTime(begin) + RANGE + dateTime(end)
} }
} }
/**
* A short label for [day] relative to [today], or `null` when it *is* today (the
* common case, which needs no prefix). Weekday names are used only within the
* current week — a "next Wednesday" would be indistinguishable from this one, so
* anything past this week falls back to the exact date.
*/
private fun relativeDayPrefix(
day: LocalDate,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
locale: Locale,
dateFormat: DateTimeFormatter,
tomorrowLabel: String,
yesterdayLabel: String,
): String? = when (ChronoUnit.DAYS.between(today, day)) {
0L -> null
1L -> tomorrowLabel
-1L -> yesterdayLabel
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
} else {
dateFormat.format(day)
}
}
/**
* True when [day] and [today] share the same week. The week boundary honours the
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
* with [firstDayOfWeek] falling back to the locale default upstream).
*/
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
val startOfWeek = today.previousOrSame(firstDayOfWeek)
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
}
/** The most recent [target] on or before this date (this date itself when it matches). */
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
val backtrack = (dayOfWeek.value - target.value + 7) % 7
return minusDays(backtrack.toLong())
}
private const val RANGE = " " private const val RANGE = " "

View File

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

View File

@@ -0,0 +1,225 @@
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 = "")
/**
* The four fields [filterTimeZones] matches on, normalized once here rather
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
* mark strip — and a search re-examines every option on every keystroke, so
* doing it at construction turns ~2400 normalizations per character into a few
* hundred plain `startsWith`/`contains` calls.
*
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
* it is derived state, and two options with the same id are the same option.
*/
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
city = city.normalizeForSearch(),
id = id.normalizeForSearch(),
displayName = displayName.normalizeForSearch(),
shortName = shortName.normalizeForSearch(),
)
}
/** Pre-normalized match targets for one [TimeZoneOption]. */
internal data class TimeZoneSearchKeys(
val city: String,
val id: String,
val displayName: String,
val shortName: String,
)
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]. [regionOf] (see
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
*
* This is ~600 entries, each costing a localized display name plus up to two ICU
* short-name lookups, so it is **not** cheap enough for the main thread — build
* it off-thread once and filter the result with [filterTimeZones] rather than
* rebuilding per keystroke.
*
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
* dropped: they're aliases the tz database keeps for compatibility, they'd
* double up the real zones in the list, and none of them is what a user means
* when they pick a place.
*/
fun timeZoneOptions(
locale: Locale = Locale.getDefault(),
at: Instant = Instant.now(),
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 keys = option.searchKeys
val rank = when {
keys.city.startsWith(needle) -> 0
keys.shortName == needle -> 1
keys.displayName.startsWith(needle) -> 2
keys.shortName.startsWith(needle) -> 3
keys.city.contains(needle) -> 4
keys.id.contains(needle) -> 5
keys.displayName.contains(needle) -> 6
else -> return@mapNotNull null
}
rank to option
}
.sortedWith(compareBy({ it.first }, { it.second.city }))
.map { it.second }
}
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
private val COMBINING_MARKS = Regex("\\p{Mn}+")
/**
* 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(COMBINING_MARKS, "")
.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

@@ -75,6 +75,8 @@ fun CalendarHost(
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null, requestedInsertForm: EventForm? = null,
onInsertConsumed: () -> Unit = {}, onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(), viewModel: CalendarHostViewModel = hiltViewModel(),
) { ) {
// Wait for the persisted default view before seeding the stack, so the app // Wait for the persisted default view before seeding the stack, so the app
@@ -86,6 +88,8 @@ fun CalendarHost(
// sensible non-empty initial values, so they're ready before the first frame. // sensible non-empty initial values, so they're ready before the first frame.
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
val drawerViewOrder = viewModel.drawerViewOrder.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) { var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
@@ -212,6 +216,20 @@ fun CalendarHost(
importForm = null importForm = null
} }
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
// widget) opens the occurrence straight in the edit form. Drop any covering
// overlay first — the edit overlay sits below Settings/import in the Box, so
// without this it would open hidden underneath them. Same held-key pattern as
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) {
dismissCoveringOverlays()
heldEditKey = requestedEditKey
editKey = requestedEditKey
onEditKeyConsumed()
}
}
// A home-screen widget launch asks to open a date (→ day view), open an // A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring // event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own // [requestedDetailKey]. Date/event opens root the stack in the widget's own
@@ -302,6 +320,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Day -> DayScreen( CalendarView.Day -> DayScreen(
selectedView = currentView, selectedView = currentView,
@@ -313,6 +332,7 @@ fun CalendarHost(
initialDateIso = pendingDayIso, initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Month -> MonthScreen( CalendarView.Month -> MonthScreen(
selectedView = currentView, selectedView = currentView,
@@ -323,6 +343,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Agenda -> AgendaScreen( CalendarView.Agenda -> AgendaScreen(
selectedView = currentView, selectedView = currentView,
@@ -334,6 +355,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
} }
} }
@@ -368,6 +390,14 @@ fun CalendarHost(
heldEditKey = key heldEditKey = key
editKey = key editKey = key
}, },
onDuplicate = { form ->
// Reuse the prefilled-create overlay: a duplicate is a
// fresh event, so it applies the default reminder like an
// in-app new event (#52).
importFormSource = ImportSource.Insert
importForm = form
detailKey = null
},
) )
} }
} }

View File

@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS, 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

@@ -37,6 +37,8 @@ fun RootScreen(
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null, requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
onInsertConsumed: () -> Unit = {}, onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var hasPermission by remember { var hasPermission by remember {
@@ -88,6 +90,8 @@ fun RootScreen(
onImportConsumed = onImportConsumed, onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm, requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed, onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
) )
false -> ReminderOnboardingScreen( false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish, onFinished = reminderOnboarding::finish,

View File

@@ -1,10 +1,9 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
/** /**
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
/** /**
* The concrete span the range covers, starting at [start] through [end] * The concrete span the range covers, starting at [start] through [end]
* (inclusive), for a human-readable header: * (inclusive), for a human-readable header. Both ends use the same day-month
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026") * order so a span never mixes "15 Jul" with "Aug 13, 2026":
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
* - [AgendaRange.ThisMonth] → month and year ("June 2026") * - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026") * - everything else → "start end" ("27 Jun 3 Jul 2026"), with the start's
* year shown too only when it differs from the end's.
*/ */
fun agendaRangeWindowSummary( fun agendaRangeWindowSummary(
range: AgendaRange, range: AgendaRange,
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
): String { ): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when (range) { return when (range) {
AgendaRange.Day -> medium.format(javaStart) AgendaRange.Day -> dayMonthYear.format(javaStart)
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart) AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} ${medium.format(javaEnd)}" else -> {
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
"${startFmt.format(javaStart)} ${dayMonthYear.format(javaEnd)}"
}
} }
} }

View File

@@ -15,16 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -58,22 +59,25 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -83,10 +87,11 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
private val zone = TimeZone.currentSystemDefault() // No file-level zone constant here on purpose: it would be fixed for the process
// lifetime and drift from the zone AgendaViewModel groups in after a device
// time-zone change. The zone travels on AgendaUiState.Success instead.
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -100,12 +105,14 @@ fun AgendaScreen(
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -148,12 +155,14 @@ fun AgendaScreen(
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnToday, todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.agenda_today_action), todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday, onToday = viewModel::goToToday,
onCreate = { onCreateEvent(anchor, null) }, onCreate = { onCreateEvent(anchor, null) },
@@ -170,9 +179,11 @@ fun AgendaScreen(
successState?.takeIf { it.showRangeBar }?.let { s -> successState?.takeIf { it.showRangeBar }?.let { s ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), .padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) { ) {
AgendaRangeBanner( AgendaRangeBanner(
range = s.range, range = s.range,
@@ -190,6 +201,7 @@ fun AgendaScreen(
AgendaContent( AgendaContent(
state = state, state = state,
pastDisplay = pastDisplay, pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -213,9 +225,11 @@ fun AgendaScreen(
} }
/** /**
* A compact tonal pill showing the agenda's current range. Tapping it opens the * The agenda's current range, tapped to open the range picker as a session-only
* range picker as a session-only override. Filled with the primary container * override. Shares the top-bar view switcher's button shape so the two read as a
* while an override is active, so the temporary state is obvious. * family, but stays low-emphasis — a subtle neutral surface tint rather than the
* switcher's secondary container, so it doesn't compete. Fills with the primary
* container only while an override is active, to make that temporary state clear.
*/ */
@Composable @Composable
private fun AgendaRangePill( private fun AgendaRangePill(
@@ -224,43 +238,34 @@ private fun AgendaRangePill(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val container = if (isOverride) { FilledTonalButton(
MaterialTheme.colorScheme.primaryContainer onClick = onClick,
} else { shape = MaterialTheme.shapes.large,
MaterialTheme.colorScheme.surfaceContainerHigh colors = ButtonDefaults.filledTonalButtonColors(
} containerColor = if (isOverride) {
val content = if (isOverride) { MaterialTheme.colorScheme.primaryContainer
MaterialTheme.colorScheme.onPrimaryContainer } else {
} else { MaterialTheme.colorScheme.surfaceContainerHigh
MaterialTheme.colorScheme.onSurfaceVariant },
} contentColor = if (isOverride) {
Surface( MaterialTheme.colorScheme.onPrimaryContainer
color = container, } else {
contentColor = content, MaterialTheme.colorScheme.onSurfaceVariant
shape = RoundedCornerShape(50), },
modifier = modifier.clickable(onClick = onClick), ),
modifier = modifier,
) { ) {
Row( Text(
verticalAlignment = Alignment.CenterVertically, text = agendaRangeLabel(range),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), style = MaterialTheme.typography.labelLarge,
) { )
Icon(
imageVector = Icons.Filled.DateRange,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = agendaRangeLabel(range),
style = MaterialTheme.typography.labelLarge,
)
}
} }
} }
/** /**
* A header naming the concrete window currently shown, e.g. "Showing all events * A header naming the concrete window currently shown under a "showing …" label,
* for · Today, 27 Jun 2026" / "This week, 27 Jun 3 Jul" / "This month, June 2026". * e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
* lives on the selector button beside it, so it isn't repeated here.
*/ */
@Composable @Composable
private fun AgendaRangeBanner( private fun AgendaRangeBanner(
@@ -277,8 +282,10 @@ private fun AgendaRangeBanner(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// Just the concrete dates — the range's name ("Next 30 days") already
// sits on the selector button to the right, so repeating it here is noise.
Text( Text(
text = "${agendaRangeLabel(range)}, $window", text = window,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@@ -289,6 +296,7 @@ private fun AgendaRangeBanner(
private fun AgendaContent( private fun AgendaContent(
state: AgendaUiState, state: AgendaUiState,
pastDisplay: PastEventDisplay, pastDisplay: PastEventDisplay,
showToday: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -304,7 +312,7 @@ private fun AgendaContent(
// Hiding drops finished events — and any day they leave empty; dimming // Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall // keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open. // away (or fade) as they end while the screen stays open.
val days = if (pastDisplay == PastEventDisplay.HIDE) { val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day -> state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) } val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining) if (remaining.isEmpty()) null else day.copy(events = remaining)
@@ -312,12 +320,21 @@ private fun AgendaContent(
} else { } else {
state.days state.days
} }
// Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in
// it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing(
days = filtered,
today = state.today,
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) { if (days.isEmpty()) {
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
AgendaList( AgendaList(
days = days, days = days,
today = state.today, today = state.today,
zone = state.zone,
dimPast = pastDisplay == PastEventDisplay.DIM, dimPast = pastDisplay == PastEventDisplay.DIM,
now = now, now = now,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -334,6 +351,7 @@ private fun AgendaContent(
private fun AgendaList( private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
today: LocalDate, today: LocalDate,
zone: TimeZone,
dimPast: Boolean, dimPast: Boolean,
now: Instant, now: Instant,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -349,17 +367,29 @@ private fun AgendaList(
stickyHeader(key = "header-${day.date}") { stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
} }
itemsIndexed( if (day.events.isEmpty()) {
items = day.events, // An anchored, event-less today (#35) — "nothing left today".
key = { _, event -> event.instanceId }, item(key = "placeholder-${day.date}") {
) { index, event -> AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
AgendaEventRow( }
event = event, } else {
position = positionOf(index, day.events.size), itemsIndexed(
dimmed = dimPast && event.hasEnded(now), items = day.events,
modifier = animateItemMotion(), // Scope the key by day: a multi-day event appears under every
onClick = { onEventClick(event) }, // 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,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
)
}
} }
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) } item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
} }
@@ -391,20 +421,61 @@ private fun AgendaDayHeader(
} }
} }
/**
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
* visible slot when nothing is left rather than a bare header.
*/
@Composable
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
Card(
// Match a single event row's resting corner radius (floret groupedShape).
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Filled.Coffee,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.agenda_no_more_today),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable @Composable
private fun AgendaEventRow( private fun AgendaEventRow(
event: EventInstance, event: EventInstance,
day: LocalDate,
zone: TimeZone,
position: Position, position: Position,
dimmed: Boolean, dimmed: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow( GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title, title = title,
summary = agendaTimeSummary(event), summary = agendaTimeSummary(event, day, zone),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,
leading = { leading = {
@@ -412,7 +483,7 @@ private fun AgendaEventRow(
modifier = Modifier modifier = Modifier
.size(width = 6.dp, height = 36.dp) .size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)), .background(eventFill(event.color, dark, soften)),
) )
}, },
onClick = onClick, onClick = onClick,
@@ -448,6 +519,8 @@ private fun AgendaTopBar(
onCycleView: () -> Unit, onCycleView: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
) { ) {
TopAppBar( TopAppBar(
@@ -466,6 +539,7 @@ private fun AgendaTopBar(
} }
}, },
actions = { actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) { IconButton(onClick = onOpenSearch) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
@@ -498,21 +572,43 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
return if (relative != null) "$relative · $formatted" else formatted 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 @Composable
private fun agendaTimeSummary(event: EventInstance): String { private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
val time = if (event.isAllDay) { val is24Hour = LocalUse24HourFormat.current
stringResource(R.string.event_detail_all_day) val locale = currentLocale()
} else {
val is24Hour = LocalUse24HourFormat.current val time = when (val label = agendaTimeLabel(event, day, zone)) {
val locale = currentLocale() AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
"${formatTime(event.start, is24Hour, locale)} ${formatTime(event.end, is24Hour, locale)}" is AgendaTimeLabel.Starts -> stringResource(
R.string.agenda_span_starts,
formatTime(label.start, zone, is24Hour, locale),
)
is AgendaTimeLabel.Ends -> stringResource(
R.string.agenda_span_ends,
formatTime(label.end, zone, is24Hour, locale),
)
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} " +
formatTime(label.end, zone, is24Hour, locale)
} }
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String { private fun formatTime(
instant: Instant,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String {
val t = instant.toLocalDateTime(zone).time val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
} }
@@ -520,7 +616,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
private fun formatAgendaDate(date: LocalDate): String { private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return "$weekday, ${date.day}. $monthName ${date.year}" return localizedDateFormatter(locale, "EEEdMMMy").format(java)
} }

View File

@@ -2,9 +2,70 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime 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. */ /** One calendar day with at least one event, for the agenda list. */
data class AgendaDay( data class AgendaDay(
@@ -15,31 +76,69 @@ data class AgendaDay(
/** /**
* Group flat [instances] into forward-looking [AgendaDay]s (only days that * Group flat [instances] into forward-looking [AgendaDay]s (only days that
* actually carry events). An event that began before [anchor] (ongoing or * actually carry events). A multi-day event surfaces on *every* day it spans,
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a * not just its first — clamped to [[anchor], [windowEnd]] so an event that began
* day, all-day events sort first, then ascending by start time, then title. * 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 * Shared by the Agenda screen and the agenda home-screen widget so both group
* and order identically. * and order identically.
*/ */
fun groupAgendaDays( fun groupAgendaDays(
anchor: LocalDate, anchor: LocalDate,
windowEnd: LocalDate,
instances: List<EventInstance>, instances: List<EventInstance>,
zone: TimeZone, zone: TimeZone,
): List<AgendaDay> = ): List<AgendaDay> {
instances val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) } for (instance in instances) {
.toSortedMap() val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
.map { (date, dayEvents) -> val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
AgendaDay( // Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
date = date, // The provider returns an event whenever its instant span overlaps the query
events = dayEvents.sortedWith( // window, but all-day events live at UTC midnights with an exclusive end: east
compareByDescending<EventInstance> { it.isAllDay } // of UTC that end dips just past local midnight, so *yesterday's* all-day event
.thenBy { it.start } // overlaps today's window start and comes back even though its true last day
.thenBy { it.title }, // (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
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top
* so a glance tells today's events apart from a future day's (issue #35).
*/
fun anchorTodayIfMissing(
days: List<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
/** /**
* State for the Agenda view: a flat, forward-looking list of upcoming events * State for the Agenda view: a flat, forward-looking list of upcoming events
@@ -61,5 +160,12 @@ sealed interface AgendaUiState {
val rangeEnd: LocalDate, val rangeEnd: LocalDate,
/** Whether to show the top range bar — header + switcher (toggle, on by default). */ /** Whether to show the top range bar — header + switcher (toggle, on by default). */
val showRangeBar: Boolean, val showRangeBar: Boolean,
/**
* The zone [days] were grouped in. Carried in the state rather than
* re-read by the screen so labelling and grouping cannot disagree: an
* event's "Starts …/Ends …/All day" line is only correct relative to the
* same zone that decided which day it was filed under.
*/
val zone: TimeZone,
) : AgendaUiState ) : AgendaUiState
} }

View File

@@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -51,8 +49,7 @@ class AgendaViewModel @Inject constructor(
) { range, showBar -> AgendaSettings(range, showBar) } ) { range, showBar -> AgendaSettings(range, showBar) }
// First day of the week, for the calendar-aligned "this week" range. // First day of the week, for the calendar-aligned "this week" range.
private val weekStartDay = settingsPrefs.weekStart private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
.map { it.resolveFirstDay(Locale.getDefault()) }
/** /**
* How to treat events that already ended today (show / dim / hide). A display * How to treat events that already ended today (show / dim / hide). A display
@@ -66,6 +63,19 @@ class AgendaViewModel @Inject constructor(
initialValue = PastEventDisplay.SHOW, initialValue = PastEventDisplay.SHOW,
) )
/**
* Whether to keep today anchored at the top with a "nothing left today"
* placeholder even once it has no remaining events (#35). A display concern
* applied in the composition (after past-event filtering), so it rides
* alongside the data state like [pastEventDisplay] rather than re-querying.
*/
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true,
)
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate
@@ -146,11 +156,11 @@ class AgendaViewModel @Inject constructor(
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
} }
val anchor = params.anchor val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone)
val rangeEnd = anchor.plus( val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1, params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = anchor, anchor = anchor,
today = todayDate, today = todayDate,
@@ -159,6 +169,7 @@ class AgendaViewModel @Inject constructor(
rangeIsOverride = params.rangeIsOverride, rangeIsOverride = params.rangeIsOverride,
rangeEnd = rangeEnd, rangeEnd = rangeEnd,
showRangeBar = params.showRangeBar, showRangeBar = params.showRangeBar,
zone = zone,
) )
} }
} }

View File

@@ -93,6 +93,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
@@ -108,7 +110,6 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import java.time.LocalDate import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */ /** Sentinel [editorId] meaning "the editor is composing a new calendar". */
@@ -565,6 +566,7 @@ private fun CalendarEditor(
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) } var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
@@ -624,7 +626,7 @@ private fun CalendarEditor(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) { EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
InlineTextField( InlineTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },

View File

@@ -14,16 +14,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/** /**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted * Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
* in the calendar's (pastelised) colour. Shared by the calendar manager and the * in the calendar's colour — softened to a pastel, or raw when the softener is
* visibility filter so they read identically. * off (issue #36). Shared by the calendar manager and the visibility filter so
* they read identically.
*/ */
@Composable @Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) { fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Box( Box(
modifier = modifier modifier = modifier
.size(40.dp) .size(40.dp)
@@ -34,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
Icon( Icon(
Icons.Filled.CalendarMonth, Icons.Filled.CalendarMonth,
contentDescription = null, contentDescription = null,
tint = pastelize(color, dark), tint = eventFill(color, dark, soften),
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
} }

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -33,6 +32,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/** /**
* The app's single "which calendar" selection list, shared by the event editor * The app's single "which calendar" selection list, shared by the event editor
@@ -99,13 +99,7 @@ private fun CalendarPickerGroup(
selected = isSelected, selected = isSelected,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) { trailing = if (isSelected) {
{ { SelectedCheck() }
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else { } else {
null null
}, },

View File

@@ -0,0 +1,34 @@
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.
*
* [forceYear] overrides that for titles whose [date] does not tell the whole
* story — a week view's title names only the month its *first* day falls in, so
* a week straddling New Year must still show the year even though [date] is in
* [currentYear].
*/
fun formatCalendarTitle(
date: java.time.LocalDate,
locale: Locale,
currentYear: Int,
skeleton: String,
forceYear: Boolean = false,
): String {
val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y"
return localizedDateFormatter(locale, fields).format(date)
}

View File

@@ -18,15 +18,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/** /**
* A wrapping row of round colour swatches; the one matching [selected] is * A wrapping row of round colour swatches; the one matching [selected] is
* ringed and checked. Shared by the calendar editor and the event-colour * ringed and checked. Shared by the calendar editor and the event-colour
* picker so both pick a colour the same way. Swatches render through * picker so both pick a colour the same way. Swatches render through
* [pastelize] — the softened colour the app actually paints, not the raw hue. * [eventFill] — the colour the app actually paints (softened, or raw when the
* softener is off, issue #36), not necessarily the stored hue.
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@@ -37,16 +36,18 @@ fun ColorSwatchRow(
dark: Boolean, dark: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val soften = LocalSoftenColors.current
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) { FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
colors.forEach { argb -> colors.forEach { argb ->
val isSelected = argb == selected val isSelected = argb == selected
val fill = eventFill(argb, dark, soften)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(pastelize(argb, dark)) .background(fill)
.then( .then(
if (isSelected) { if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
@@ -60,7 +61,7 @@ fun ColorSwatchRow(
Icon( Icon(
Icons.Default.Check, Icons.Default.Check,
contentDescription = null, contentDescription = null,
tint = Color.Black.copy(alpha = 0.7f), tint = eventInk(fill, alpha = 0.7f),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
} }

View File

@@ -0,0 +1,46 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import de.jeanlucmakiola.floret.components.pastelize
/**
* Whether calendar/event colours are softened toward theme-fitting pastels
* before display (issue #36). Provided app-wide from the "soften colours"
* setting; the default `true` keeps the historical look. When off, the raw
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
* and other calendar apps. Widgets live outside this composition and read the
* preference directly, then pass the flag to [eventFill] / [eventInk].
*/
val LocalSoftenColors = staticCompositionLocalOf { true }
/**
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
* pastelize also returns an opaque colour).
*/
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
/**
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
* white on a dark fill, near-black on a light one (issue #21). Applies to both
* softened and raw fills — a saturated hue (deep blue, purple, red) is
* perceptually dark even after softening pins its HSV value, so black text on it
* reads poorly. The choice is objective, not a tuned threshold: white wins only
* when it out-contrasts black against the fill, which (by the WCAG contrast
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
* near-black text. [alpha] carries the caller's soft emphasis.
*/
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
}
/**
* Relative-luminance crossover where white text starts to out-contrast black.
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
*/
private const val INK_LUMINANCE_CROSSOVER = 0.179f

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

View File

@@ -1,20 +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.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()
}
}

View File

@@ -1,26 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -29,16 +16,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
@@ -195,56 +182,18 @@ private fun CustomReminderEditor(
onConfirm: (Int) -> Unit, onConfirm: (Int) -> Unit,
) { ) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 } val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface( CustomAmountEditor(
color = MaterialTheme.colorScheme.surfaceContainerHigh, amountText = amountText,
// A Position.Bottom shape: tight top corners meeting the row, full bottom. onAmountChange = onAmountChange,
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
modifier = Modifier selectedUnit = unit.ordinal,
.fillMaxWidth() onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
.padding(horizontal = 16.dp), preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
) { ?: stringResource(R.string.reminder_custom_amount),
Column( setLabel = stringResource(R.string.reminder_custom_set),
modifier = Modifier.padding(16.dp), confirmEnabled = amount != null,
verticalArrangement = Arrangement.spacedBy(16.dp), onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
) { )
// Unit toggle first so it stays visible above the keyboard once the
// amount field (the bottom row) is focused and scrolled into view.
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ReminderUnit.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = unit == entry,
onClick = { onUnitChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
label = { Text(stringResource(reminderUnitLabel(entry))) },
)
}
}
// Amount, a live preview of the lead time it resolves to, and Set —
// all on one row, sitting just above the keyboard.
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "10",
)
Spacer(Modifier.width(16.dp))
Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
enabled = amount != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
} }
/** A short explanatory paragraph shown under a picker's title, above the rows. */ /** A short explanatory paragraph shown under a picker's title, above the rows. */
@@ -258,14 +207,6 @@ private fun PickerDescription(text: String) {
) )
} }
@Composable
private fun SelectedCheck() {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
/** /**
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
@@ -364,41 +305,136 @@ private fun CustomDaysEditor(
) { ) {
val days = amountText.toIntOrNull() val days = amountText.toIntOrNull()
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS } ?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
Surface( CustomAmountEditor(
color = MaterialTheme.colorScheme.surfaceContainerHigh, amountText = amountText,
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), onAmountChange = onAmountChange,
modifier = Modifier placeholder = "30",
.fillMaxWidth() preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
.padding(horizontal = 16.dp), ?: stringResource(R.string.agenda_range_custom_hint),
) { setLabel = stringResource(R.string.reminder_custom_set),
Row( confirmEnabled = days != null,
modifier = Modifier.padding(16.dp), onConfirm = { days?.let(onConfirm) },
verticalAlignment = Alignment.CenterVertically, )
}
/**
* Snooze-duration picker, full-screen and **single-select**: the [presets]
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
* a preset or confirming a custom value applies via [onSelect] and closes.
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
* is reused for both the rows and the custom preview.
*/
@Composable
fun SnoozeDurationPicker(
title: String,
presets: List<Int>,
selected: Int,
label: @Composable (Int) -> String,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val customSelected = selected !in presets
val rowCount = presets.size + 1 // + the custom row
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
}
var unit by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow(
title = label(minute),
position = positionOf(index, rowCount),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(minute)
onDismiss()
},
)
}
// The Custom row connects downward into the editor card when expanded, so
// the two read as one grouped container (the shared custom-expand pattern).
GroupedRow(
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) { ) {
DialogAmountField( CustomSnoozeEditor(
value = amountText, amountText = amountText,
onValueChange = onAmountChange, onAmountChange = { amountText = it },
placeholder = "30", unit = unit,
onUnitChange = { unit = it },
label = label,
onConfirm = { minutes ->
onSelect(minutes)
onDismiss()
},
) )
Spacer(Modifier.width(16.dp))
Text(
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
?: stringResource(R.string.agenda_range_custom_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { days?.let(onConfirm) },
enabled = days != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
} }
} }
} }
/** Whole hours if the delay divides evenly, else minutes. */
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
private fun snoozeCustomAmount(minutes: Int): Int =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
/**
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
* the delay it resolves to, and a tonal confirm enabled only for a valid 1999
* amount. [onConfirm] receives the final delay in minutes.
*/
@Composable
private fun CustomSnoozeEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
label: @Composable (Int) -> String,
onConfirm: (Int) -> Unit,
) {
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
onUnitChange = { onUnitChange(units[it]) },
preview = amount?.let { label(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
)
}
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */ /** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
@Composable @Composable
fun agendaRangeLabel(range: AgendaRange): String = when (range) { fun agendaRangeLabel(range: AgendaRange): String = when (range) {

View File

@@ -9,6 +9,10 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
@@ -111,16 +115,36 @@ private fun rruleDayName(token: String, locale: Locale): String? {
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */ /** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? { private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() } val date = untilLocalDate(raw) ?: return null
if (digits.length < 8) return null return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
return try { }
val date = java.time.LocalDate.of(
private val UNTIL_UTC_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
/**
* The calendar day an RRULE UNTIL value denotes, *in the device's zone*.
*
* The UTC form ("20261231T225959Z") must be converted back before its date is
* read: `SimpleRecurrence.toRRule` writes the end of the chosen **local** day
* expressed in UTC, so for zones behind UTC that instant already falls on the
* following UTC date. Taking the leading digits straight off would then show
* the day after the one the user picked. Date-only and floating forms are
* already local and pass through unchanged.
*/
internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? {
val value = raw.trim()
return runCatching {
LocalDateTime.parse(value, UNTIL_UTC_FORMAT)
.atOffset(ZoneOffset.UTC)
.atZoneSameInstant(zone)
.toLocalDate()
}.recoverCatching {
val digits = value.takeWhile { it.isDigit() }
LocalDate.of(
digits.substring(0, 4).toInt(), digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(), digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(), digits.substring(6, 8).toInt(),
) )
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) }.getOrNull()
} catch (e: Exception) {
null
}
} }

View File

@@ -0,0 +1,267 @@
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.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.produceState
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.timeZoneOptionOf
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.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 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("") }
val locale = currentLocale()
// ~600 zones, each resolving a localized name and up to two ICU short names:
// far too much to run inside composition, so build it on a worker and let the
// list fill in a frame later. Filtering the built catalogue is cheap (its
// search keys are pre-normalized), so that stays here.
val allZones by produceState(initialValue = emptyList<TimeZoneOption>(), locale) {
value = withContext(Dispatchers.Default) {
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 } }
}
// Resolved on its own rather than looked up in the catalogue: it's one zone,
// so it costs nothing, and the device row can then render complete on the
// first frame instead of showing a bare id until the catalogue lands.
val deviceSummary = remember(deviceZoneId, locale) {
timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion)
?.let { zoneDescriptor(it) }
?: deviceZoneId
}
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 = deviceSummary,
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))
}
}
// allZones.isNotEmpty() gates this: until the catalogue lands there is
// simply nothing to match yet, and claiming "no time zone matches"
// for that frame would be wrong.
if (searching && matches.isEmpty() && allZones.isNotEmpty()) {
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 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,
)
}

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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -79,9 +81,11 @@ import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize 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.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
@@ -91,12 +95,18 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.format.TextStyle as JavaTextStyle import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's hour labels so they centre on the top bar's
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
* 4dp inset + 24dp half icon button), matching the week view. */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val MIN_EVENT_HEIGHT = 24.dp private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -119,6 +129,7 @@ fun DayScreen(
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialDateIso: String? = null, initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(), viewModel: DayViewModel = hiltViewModel(),
@@ -151,6 +162,13 @@ fun DayScreen(
else -> true else -> true
} }
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is DayUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump. // Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() } val goNext = { slideDir = 1; viewModel.goToNext() }
@@ -199,16 +217,19 @@ fun DayScreen(
topBar = { topBar = {
DayTopBar( DayTopBar(
date = date, date = date,
currentYear = currentYear,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnToday, todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.day_today_action), todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday, onToday = jumpToToday,
onCreate = { onCreateEvent(date, null) }, onCreate = { onCreateEvent(date, null) },
@@ -354,16 +375,20 @@ private fun DaySuccess(
@Composable @Composable
private fun DayTopBar( private fun DayTopBar(
date: LocalDate, date: LocalDate,
currentYear: Int,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale()
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = formatDayTitle(date), text = formatDayTitle(date, locale, currentYear),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
}, },
@@ -376,6 +401,7 @@ private fun DayTopBar(
} }
}, },
actions = { actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) { IconButton(onClick = onOpenSearch) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
@@ -448,9 +474,11 @@ private fun AllDayBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -461,7 +489,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f), color = eventInk(fill),
) )
} }
} }
@@ -484,10 +512,12 @@ private fun Timeline(
// static, rounded-clipped window — the content scrolls inside it, so the // static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position. // soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day column) // Hour gutter (scrolls in sync with the day column). Start inset so the
// labels centre on the top bar hamburger, matching the week view.
Column( Column(
modifier = Modifier modifier = Modifier
.width(GUTTER_WIDTH) .width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight() .fillMaxHeight()
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {
@@ -610,9 +640,11 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale) minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45 val showTime = block.endMin - block.startMin >= 45
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" }, .semantics { contentDescription = "$title, $timeLabel" },
@@ -623,7 +655,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2, maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
) )
if (showTime) { if (showTime) {
Text( Text(
@@ -631,7 +663,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f), color = eventInk(fill, alpha = 0.6f),
) )
} }
} }
@@ -657,10 +689,10 @@ private fun DayLoading() {
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale) formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate): String { private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
val locale = Locale.getDefault() formatCalendarTitle(
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) locale = locale,
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) currentYear = currentYear,
return "$weekday, ${date.day}. $monthName ${date.year}" skeleton = "EEEdMMM",
} )

View File

@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
@@ -87,25 +88,31 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder 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.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure 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.floret.components.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlin.time.toJavaInstant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
@@ -116,7 +123,10 @@ import kotlin.time.Instant
* top-bar arrow both return to the calendar. Events in writable calendars can * top-bar arrow both return to the calendar. Events in writable calendars can
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared * be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
* event form for this occurrence — for recurring events the form asks how * event form for this occurrence — for recurring events the form asks how
* far the change reaches when saving. * far the change reaches when saving. [onDuplicate] opens the same form
* seeded with a copy of this event as a new, unsaved event (#52) — offered
* for any event, including read-only ones, since the copy lands in an
* editable calendar.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -126,6 +136,7 @@ fun EventDetailScreen(
endMillis: Long, endMillis: Long,
onBack: () -> Unit, onBack: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDuplicate: (EventForm) -> Unit,
viewModel: EventDetailViewModel = hiltViewModel(), viewModel: EventDetailViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(eventId, beginMillis, endMillis) { LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -160,15 +171,14 @@ fun EventDetailScreen(
} }
// v1.0 installs only hold READ_CALENDAR; the first write asks for the // v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action. // upgrade in place. Granting continues straight into the tapped action
var pendingEdit by remember { mutableStateOf(false) } // edit, delete, or duplicate — held here until the result comes back.
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult( val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
) { granted -> ) { granted ->
if (granted) { if (granted) pendingWrite?.invoke()
if (pendingEdit) onEdit() else showDeleteDialog = true pendingWrite = null
}
pendingEdit = false
} }
val hasWritePermission = { val hasWritePermission = {
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@@ -176,21 +186,20 @@ fun EventDetailScreen(
Manifest.permission.WRITE_CALENDAR, Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} }
val onDeleteClick = { val requireWrite: (() -> Unit) -> Unit = { action ->
if (hasWritePermission()) { if (hasWritePermission()) {
showDeleteDialog = true action()
} else { } else {
pendingEdit = false pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
} }
} }
val onEditClick = { val onDeleteClick = { requireWrite { showDeleteDialog = true } }
if (hasWritePermission()) { val onEditClick = { requireWrite(onEdit) }
onEdit() // Duplicate reads the loaded detail into a create form and hands it up; the
} else { // create still needs WRITE_CALENDAR even when the source is read-only.
pendingEdit = true val onDuplicateClick = {
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
}
} }
val deleteFailedMessage = stringResource(R.string.event_delete_failed) val deleteFailedMessage = stringResource(R.string.event_delete_failed)
@@ -229,7 +238,8 @@ fun EventDetailScreen(
}, },
actions = { actions = {
val s = state val s = state
// Share works for any loaded event — it only reads the event. // Share and duplicate work for any loaded event — both only
// read it; the duplicate is created into a writable calendar.
if (s is EventDetailUiState.Success) { if (s is EventDetailUiState.Success) {
IconButton(onClick = onShareClick) { IconButton(onClick = onShareClick) {
Icon( Icon(
@@ -237,6 +247,15 @@ fun EventDetailScreen(
contentDescription = stringResource(R.string.event_detail_share), contentDescription = stringResource(R.string.event_detail_share),
) )
} }
IconButton(
onClick = onDuplicateClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.event_detail_duplicate),
)
}
} }
// Edit/delete need a writable calendar — WebCal subscriptions, // Edit/delete need a writable calendar — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level. // birthday calendars etc. are read-only at the provider level.
@@ -367,7 +386,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
val instance = detail.instance val instance = detail.instance
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val locale = currentDetailLocale() val locale = currentDetailLocale()
val accent = pastelize(instance.color, dark) val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
Column( Column(
modifier = modifier modifier = modifier
@@ -438,14 +457,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
} }
// Time zone — only when the event is timed and pinned to a zone other // Time zone — only when the event is timed and pinned to a zone other
// than the device's, so cross-zone events read unambiguously. // than the device's, so cross-zone events read unambiguously. It answers
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> // "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, instance.start, locale) {
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start)
}
foreignZone?.let { zoneOption ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( DetailCard(
icon = Icons.Default.Public, icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone), 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,
)
} }
} }
@@ -737,21 +786,30 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes) private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/** /**
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"), * [tz] resolved as a [TimeZoneOption], but only when the event is timed and
* but only when the event is timed and pinned to a zone different from the * pinned to a zone different from the device's — the cases where showing it
* device's. Returns null when there's nothing worth showing. * removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
* tz database doesn't know).
*
* Resolved *at [at]*, the event's own start, not at "now": the abbreviation and
* offset both move with DST, and the card prints them next to the event's time.
* Resolving at now would label a July event "CET · 10:00 AM" when read in
* January — an abbreviation that contradicts the time beside it.
*/ */
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? { private fun foreignTimeZone(
tz: String?,
isAllDay: Boolean,
locale: Locale,
at: Instant,
): TimeZoneOption? {
if (isAllDay || tz.isNullOrBlank()) return null if (isAllDay || tz.isNullOrBlank()) return null
val deviceZone = ZoneId.systemDefault().id if (tz == ZoneId.systemDefault().id) return null
if (tz == deviceZone) return null return timeZoneOptionOf(
return try { tz,
val zone = ZoneId.of(tz) locale,
val name = zone.getDisplayName(JavaTextStyle.FULL, locale) at = at.toJavaInstant(),
if (name == tz) tz else "$name ($tz)" regionOf = ::icuTimeZoneRegion,
} catch (e: Exception) { )
tz
}
} }
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */

View File

@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.time.Clock import kotlin.time.Clock
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -138,6 +141,31 @@ class EventDetailViewModel @Inject constructor(
}.getOrNull() }.getOrNull()
} }
/**
* Build a create form seeded from the open event so the user can save a
* copy as a new, independent event (#52). Returns null when nothing is
* loaded.
*
* The recurrence is dropped — a duplicate is a single event; anyone who
* wants a series can re-add one in the edit form that opens. The source
* calendar is kept only when it's writable, otherwise the id is cleared so
* the create resolves to the last-used/first-writable calendar — which lets
* a read-only event (a WebCal subscription, a birthday) be copied into an
* editable calendar. The occurrence's own times carry over unchanged.
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
val detail = loaded.detail
return detail.toEditForm(
beginMillis = detail.instance.start.toEpochMilliseconds(),
endMillis = detail.instance.end.toEpochMilliseconds(),
zone = TimeZone.currentSystemDefault(),
).copy(
rrule = null,
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
)
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try { private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId) val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this // The Events row holds the series start; replace it with this

View File

@@ -98,7 +98,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -111,6 +110,9 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timesIn
import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
@@ -126,27 +128,33 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -156,13 +164,14 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.toJavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.time.temporal.WeekFields
import java.util.Locale import java.util.Locale
import kotlin.time.Clock import kotlin.time.Clock
@@ -494,6 +503,7 @@ private fun EventEditContent(
) { ) {
val form = state.form val form = state.form
val locale = currentLocale() val locale = currentLocale()
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
// The title, date and recurrence are managed by the special-dates sync, so // The title, date and recurrence are managed by the special-dates sync, so
// they're locked here; everything else (reminders, location, notes) is the // they're locked here; everything else (reminders, location, notes) is the
@@ -505,6 +515,7 @@ private fun EventEditContent(
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
var showColorPicker by rememberSaveable { mutableStateOf(false) } var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) }
// Pick a guest from contacts: a one-shot system picker returning the chosen // Pick a guest from contacts: a one-shot system picker returning the chosen
@@ -541,7 +552,8 @@ private fun EventEditContent(
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
// The accent ties the form to the detail screen's design language: the // The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour. // bar under the title takes the target calendar's colour.
val accent = selectedCalendar?.let { pastelize(it.color, dark) } val soften = LocalSoftenColors.current
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
?: MaterialTheme.colorScheme.primary ?: MaterialTheme.colorScheme.primary
val gap = 12.dp val gap = 12.dp
@@ -668,19 +680,42 @@ private fun EventEditContent(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
// The rows above edit a pinned event in *its own* zone, which is the
// time it was set at — but that says nothing about when it lands for
// whoever is reading it. Spell the local equivalent out rather than
// leaving the user to do the offset arithmetic. Absent (null) unless
// the event is pinned somewhere other than here.
// Keyed rather than recomputed: this sits in the same Column as the
// title field, so it would otherwise re-parse the zone on every
// keystroke.
val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) {
form.timesIn(TimeZone.currentSystemDefault())
}
localTimes?.let { (localStart, localEnd) ->
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(
R.string.event_edit_timezone_local_time,
formatTimeRange(localStart, localEnd, locale),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
// Calendar card — tap anywhere to pick the target calendar. Editing // Calendar card — tap anywhere to pick the target calendar. Picking a
// keeps the owning calendar (moving events between calendars is a // different one while editing *moves* the event (copy+delete on save,
// sync-adapter minefield; every stock calendar app locks it too). // since CALENDAR_ID can't be updated in place). Managed special-dates
// events stay locked: the contact sync owns their calendar.
EditCard( EditCard(
icon = Icons.Default.CalendarMonth, icon = Icons.Default.CalendarMonth,
iconContentDescription = stringResource(R.string.event_detail_calendar), iconContentDescription = stringResource(R.string.event_detail_calendar),
iconTint = accent, iconTint = accent,
onClick = { showCalendarPicker = true } onClick = { showCalendarPicker = true }
.takeIf { state.calendars.isNotEmpty() && !state.isEditing }, .takeIf { state.calendars.isNotEmpty() && !state.isManaged },
) { ) {
Text( Text(
text = selectedCalendar?.displayName text = selectedCalendar?.displayName
@@ -703,6 +738,54 @@ private fun EventEditContent(
// Optional sections: which ones render by default is a setting; the // Optional sections: which ones render by default is a setting; the
// rest unfold behind the "more fields" button below. // rest unfold behind the "more fields" button below.
OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
icon = Icons.Default.Public,
iconContentDescription = null,
onClick = { showTimezonePicker = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Resolved at the event's own start, not at "now": the
// abbreviation and offset shown ("CEST · GMT+02:00") must be
// the ones that apply when the event actually happens.
val pinned = remember(form.timezone, form.start, locale) {
form.timezone
?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() }
?.let { (id, zone) ->
timeZoneOptionOf(
id,
locale,
at = form.start.toInstant(zone).toJavaInstant(),
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) { OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
EditCard( EditCard(
@@ -892,7 +975,7 @@ private fun EventEditContent(
icon = Icons.Default.Palette, icon = Icons.Default.Palette,
iconContentDescription = stringResource(R.string.event_edit_color), iconContentDescription = stringResource(R.string.event_edit_color),
iconTint = if (colorSupported && swatch != null) { iconTint = if (colorSupported && swatch != null) {
pastelize(swatch, dark) eventFill(swatch, dark, soften)
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
@@ -1059,6 +1142,7 @@ private fun EventEditContent(
RecurrencePickerDialog( RecurrencePickerDialog(
current = form.rrule, current = form.rrule,
startDay = form.start.date.dayOfWeek, startDay = form.start.date.dayOfWeek,
firstDayOfWeek = firstDayOfWeek,
onSelect = { rrule -> onSelect = { rrule ->
viewModel.setRecurrence(rrule) viewModel.setRecurrence(rrule)
showRecurrencePicker = false showRecurrencePicker = false
@@ -1078,6 +1162,16 @@ private fun EventEditContent(
) )
} }
if (showTimezonePicker) {
TimeZonePickerDialog(
selected = form.timezone,
deviceZoneId = ZoneId.systemDefault().id,
recents = state.recentTimeZones,
onSelect = viewModel::setTimezone,
onDismiss = { showTimezonePicker = false },
)
}
if (showColorPicker) { if (showColorPicker) {
ColorPickerDialog( ColorPickerDialog(
palette = state.colorPalette, palette = state.colorPalette,
@@ -1220,6 +1314,7 @@ private enum class RecurrenceEndMode { Never, Until, Count }
private fun RecurrencePickerDialog( private fun RecurrencePickerDialog(
current: String?, current: String?,
startDay: DayOfWeek, startDay: DayOfWeek,
firstDayOfWeek: DayOfWeek,
onSelect: (String?) -> Unit, onSelect: (String?) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -1254,8 +1349,21 @@ private fun RecurrencePickerDialog(
val locale = currentLocale() val locale = currentLocale()
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 } // remember() must not sit behind the ?. — a slot that appears and disappears
val count = countText.toIntOrNull()?.takeIf { it in 1..999 } // as a date is picked breaks Compose's positional memoisation. Format
// unconditionally and let the null fall through to a null summary.
val untilSummary = remember(untilDate, locale) {
untilDate?.let {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
}
// A blank amount field shows its placeholder ("1" / "10") greyed out, so read
// blank as that default rather than as an error — otherwise simply clearing
// the field silently greys out OK. Only a real out-of-range value (0) is
// invalid, and that's the one case the read-out below reports.
val interval = if (intervalText.isBlank()) 1 else intervalText.toIntOrNull()?.takeIf { it in 1..999 }
val count = if (countText.isBlank()) 10 else countText.toIntOrNull()?.takeIf { it in 1..999 }
val customEnd: RecurrenceEnd? = when (endMode) { val customEnd: RecurrenceEnd? = when (endMode) {
RecurrenceEndMode.Never -> RecurrenceEnd.Never RecurrenceEndMode.Never -> RecurrenceEnd.Never
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) } RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
@@ -1291,6 +1399,11 @@ private fun RecurrencePickerDialog(
title = stringResource(R.string.event_edit_recurrence_none), title = stringResource(R.string.event_edit_recurrence_none),
position = positionOf(0, rowCount), position = positionOf(0, rowCount),
selected = current == null, selected = current == null,
trailing = if (current == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(null) }, onClick = { onSelect(null) },
) )
RecurrenceFreq.entries.forEachIndexed { index, entry -> RecurrenceFreq.entries.forEachIndexed { index, entry ->
@@ -1298,6 +1411,11 @@ private fun RecurrencePickerDialog(
title = stringResource(recurrencePresetLabel(entry)), title = stringResource(recurrencePresetLabel(entry)),
position = positionOf(index + 1, rowCount), position = positionOf(index + 1, rowCount),
selected = isPlainPreset && parsed?.freq == entry, selected = isPlainPreset && parsed?.freq == entry,
trailing = if (isPlainPreset && parsed?.freq == entry) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) }, onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
) )
} }
@@ -1305,88 +1423,195 @@ private fun RecurrencePickerDialog(
title = stringResource(R.string.event_edit_recurrence_custom), title = stringResource(R.string.event_edit_recurrence_custom),
position = positionOf(rowCount - 1, rowCount), position = positionOf(rowCount - 1, rowCount),
selected = current != null && !isPlainPreset, selected = current != null && !isPlainPreset,
trailing = if (current != null && !isPlainPreset) {
{ SelectedCheck() }
} else {
null
},
onClick = { customMode = true }, onClick = { customMode = true },
) )
} else { } else {
Column( // Section gaps live on each block (not a parent spacedBy) so the
verticalArrangement = Arrangement.spacedBy(8.dp), // weekday card's gap collapses *with* it under AnimatedVisibility —
modifier = Modifier.padding(horizontal = 24.dp), // a parent arrangement would leave a residual gap that snaps shut.
) { Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) { // A live, human-readable read-out of the rule being built, so the
Text( // effect of the controls below is never a guess. It renders
text = stringResource(R.string.event_edit_recurrence_every), // *[customResult] itself* — the exact string OK would save — so it
style = MaterialTheme.typography.titleMedium, // can never describe a different rule than the one that lands. When
) // the form can't produce a rule, it says why instead of falling
Spacer(Modifier.width(12.dp)) // back to defaults and quietly disagreeing with the controls.
DialogAmountField( // minLines reserves the second line up front: without it the whole
value = intervalText, // stack below shifts a line as the phrase grows with each weekday.
onValueChange = { intervalText = it },
placeholder = "1",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(recurrenceUnitLabel(freq)),
entries = RecurrenceFreq.entries.map {
stringResource(recurrenceUnitLabel(it))
},
onPick = { freq = RecurrenceFreq.entries[it] },
)
}
if (freq == RecurrenceFreq.Weekly) {
WeekdayToggleRow(
selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale,
)
}
Text( Text(
text = stringResource(R.string.event_edit_recurrence_ends), text = customResult?.let { recurrenceText(it, locale) }
style = MaterialTheme.typography.bodyMedium, ?: AnnotatedString(
color = MaterialTheme.colorScheme.onSurfaceVariant, stringResource(R.string.event_edit_recurrence_incomplete),
),
style = MaterialTheme.typography.titleMedium,
color = if (customResult != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.error
},
minLines = 2,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) )
}
GroupedRow( // How often: an interval amount plus a frequency segmented row —
title = stringResource(R.string.event_edit_recurrence_end_never), // all four units on show, no unit hidden behind a dropdown.
position = positionOf(0, 3), GroupedSurface(
selected = endMode == RecurrenceEndMode.Never, position = Position.Alone,
onClick = { endMode = RecurrenceEndMode.Never }, modifier = Modifier
) .padding(top = 16.dp)
GroupedRow( .padding(horizontal = 16.dp),
title = stringResource(R.string.event_edit_recurrence_end_until),
summary = untilDate?.let {
remember(it, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
},
position = positionOf(1, 3),
selected = endMode == RecurrenceEndMode.Until,
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
},
)
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_count),
position = positionOf(2, 3),
selected = endMode == RecurrenceEndMode.Count,
onClick = { endMode = RecurrenceEndMode.Count },
)
if (endMode == RecurrenceEndMode.Count) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
) { ) {
DialogAmountField( Column(
value = countText, modifier = Modifier.padding(16.dp),
onValueChange = { countText = it }, verticalArrangement = Arrangement.spacedBy(16.dp),
placeholder = "10", ) {
) Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(12.dp)) Text(
text = stringResource(R.string.event_edit_recurrence_every),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(12.dp))
DialogAmountField(
value = intervalText,
onValueChange = { intervalText = it },
placeholder = "1",
)
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
RecurrenceFreq.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = freq == entry,
onClick = { freq = entry },
shape = SegmentedButtonDefaults.itemShape(
index, RecurrenceFreq.entries.size,
),
// Drop the default check-icon slot: four segments
// share one row, and its reserved width is what
// pushes longer labels ("Monate", "Wochen") into
// an ellipsis on a compact screen. Selection is
// already carried by the segment's fill.
icon = {},
label = {
Text(
text = stringResource(recurrenceUnitLabel(entry)),
maxLines = 1,
)
},
)
}
}
}
}
// Weekday picks — only meaningful on a weekly rule. The 16dp gap
// sits inside the animated block so it grows/shrinks with the card.
AnimatedVisibility(
visible = freq == RecurrenceFreq.Weekly,
enter = expandEnter(),
exit = collapseExit(),
) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 16.dp),
) {
WeekdayToggleRow(
selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale,
firstDay = firstDayOfWeek,
modifier = Modifier.padding(16.dp),
)
}
}
// Ends: a connected never / on-a-date / after-N group, the count
// field folding into the bottom of the run when chosen.
Column(modifier = Modifier.padding(top = 16.dp)) {
Text( Text(
text = stringResource(R.string.event_edit_recurrence_times), text = stringResource(R.string.event_edit_recurrence_ends),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
) )
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_never),
position = Position.Top,
selected = endMode == RecurrenceEndMode.Never,
trailing = if (endMode == RecurrenceEndMode.Never) {
{ SelectedCheck() }
} else {
null
},
onClick = { endMode = RecurrenceEndMode.Never },
)
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_until),
summary = untilSummary,
position = Position.Middle,
selected = endMode == RecurrenceEndMode.Until,
trailing = if (endMode == RecurrenceEndMode.Until) {
{ SelectedCheck() }
} else {
null
},
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
},
)
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_count),
position = if (endMode == RecurrenceEndMode.Count) {
Position.Middle
} else {
Position.Bottom
},
selected = endMode == RecurrenceEndMode.Count,
trailing = if (endMode == RecurrenceEndMode.Count) {
{ SelectedCheck() }
} else {
null
},
onClick = { endMode = RecurrenceEndMode.Count },
)
// Animated for the same reason the weekday card is: this is the
// picker's other collapsible block, and a bare `if` here made
// the ends group snap while the card above it glided.
AnimatedVisibility(
visible = endMode == RecurrenceEndMode.Count,
enter = expandEnter(),
exit = collapseExit(),
) {
GroupedSurface(
position = Position.Bottom,
modifier = Modifier.padding(horizontal = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp),
) {
DialogAmountField(
value = countText,
onValueChange = { countText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_times),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
} }
} }
} }
@@ -1401,7 +1626,13 @@ private fun RecurrencePickerDialog(
untilIso = it.toString() untilIso = it.toString()
showUntilPicker = false showUntilPicker = false
}, },
onDismiss = { showUntilPicker = false }, onDismiss = {
showUntilPicker = false
// Backing out of the date picker with nothing chosen would leave
// "on date" selected but dateless — a state that can't be saved and
// greys out OK with no visible cause. Fall back to "never".
if (untilIso == null) endMode = RecurrenceEndMode.Never
},
) )
} }
} }
@@ -1416,14 +1647,15 @@ private fun WeekdayToggleRow(
selected: Set<DayOfWeek>, selected: Set<DayOfWeek>,
onToggle: (DayOfWeek) -> Unit, onToggle: (DayOfWeek) -> Unit,
locale: Locale, locale: Locale,
firstDay: DayOfWeek,
modifier: Modifier = Modifier,
) { ) {
val days = remember(locale) { val days = remember(firstDay) {
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek() (0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) }
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
} }
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
days.forEach { day -> days.forEach { day ->
val isSelected = day in selected val isSelected = day in selected
@@ -1476,18 +1708,14 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
} }
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */ /**
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape = * Corner shape for a card at [position] in a grouped run. Delegates to the
when (position) { * family primitive with the same radii [GroupedSurface] draws at rest — a local
Position.Alone -> RoundedCornerShape(full) * copy had drifted to 20dp, so a card sharing a run with GroupedRows had
Position.Top -> RoundedCornerShape( * visibly tighter outer corners than the rows above it.
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, */
) private fun groupedShape(position: Position): Shape =
Position.Middle -> RoundedCornerShape(small) floretGroupedShape(position, full = 22.dp, small = 6.dp)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
/** The 2dp gap that visually separates grouped cards (none after the last). */ /** The 2dp gap that visually separates grouped cards (none after the last). */
private fun groupedGap(position: Position): Modifier = when (position) { private fun groupedGap(position: Position): Modifier = when (position) {
@@ -1930,6 +2158,21 @@ private fun EditCard(
} }
} }
/**
* "2:00 PM 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
* time when both land on the same minute (an instant event shouldn't read as a
* range against itself). Dates are deliberately absent — the rows above carry
* them.
*/
@Composable
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
val use24Hour = LocalUse24HourFormat.current
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
val from = timeFormat.format(start.time.toJavaLocalTime())
val to = timeFormat.format(end.time.toJavaLocalTime())
return if (from == to) from else "$from $to"
}
/** /**
* Borderless text input used inside the cards (and as the headline title). * Borderless text input used inside the cards (and as the headline title).
* Thin wrapper over the shared [InlineTextField] so the form and the rest of * Thin wrapper over the shared [InlineTextField] so the form and the rest of

View File

@@ -27,6 +27,8 @@ data class EventEditUiState(
* neither list. * neither list.
*/ */
val hiddenFields: List<EventFormField> = emptyList(), val hiddenFields: List<EventFormField> = emptyList(),
/** Recently picked zones, most recent first — the zone picker's shortlist. */
val recentTimeZones: List<String> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */ /** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false, val isEditing: Boolean = false,
/** /**

View File

@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
@@ -21,6 +22,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.populatedFields import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -38,6 +40,8 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -218,7 +222,8 @@ class EventEditViewModel @Inject constructor(
).flowOn(io), ).flowOn(io),
colorPalette, colorPalette,
allCalendars, allCalendars,
) { local, external, palette, allCalendars -> settingsPrefs.recentTimeZones.flowOn(io),
) { local, external, palette, allCalendars, recentTimeZones ->
val form = local.form ?: return@combine null val form = local.form ?: return@combine null
val resolvedId = form.calendarId val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
@@ -234,14 +239,19 @@ class EventEditViewModel @Inject constructor(
val pickerCalendars = val pickerCalendars =
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
else external.writable else external.writable
val visibleFields = external.defaultFields + local.revealed // An all-day event is date-anchored, so a zone is meaningless on it —
// the field is withheld from both lists rather than shown as a no-op.
val offerableFields = EventFormField.entries.toSet() -
if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet()
val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields
EventEditUiState( EventEditUiState(
form = resolved, form = resolved,
calendars = pickerCalendars, calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(), problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState, saveState = local.saveState,
visibleFields = visibleFields, visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), hiddenFields = (offerableFields - visibleFields).sorted(),
recentTimeZones = recentTimeZones,
isEditing = local.editTarget != null, isEditing = local.editTarget != null,
isManaged = isManaged, isManaged = isManaged,
autofocusTitle = external.autofocusTitle, autofocusTitle = external.autofocusTitle,
@@ -259,6 +269,13 @@ class EventEditViewModel @Inject constructor(
initialValue = null, initialValue = null,
) )
/**
* First day of the week for ordering the recurrence weekday toggles — the
* app's "week starts on" preference, matching the calendar views (Auto
* falls back to the locale convention).
*/
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
/** /**
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes * Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
* from midnight) anchors the start when the form is opened by tapping a slot * from midnight) anchors the start when the form is opened by tapping a slot
@@ -396,8 +413,12 @@ class EventEditViewModel @Inject constructor(
/** /**
* Load an existing event into the form. [beginMillis]/[endMillis] are the * Load an existing event into the form. [beginMillis]/[endMillis] are the
* tapped occurrence's own times, like on the detail screen. No-op while a * tapped occurrence's own times, like on the detail screen. An external
* form is open, so user edits survive configuration changes. * "edit this event" (`ACTION_EDIT`) that names no occurrence passes
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
* form loads the event's real times instead of the epoch (mirrors the
* detail screen's #48 fallback). No-op while a form is open, so user edits
* survive configuration changes.
*/ */
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) { fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return if (_form.value != null || _editTarget.value != null) return
@@ -411,8 +432,12 @@ class EventEditViewModel @Inject constructor(
return@launch return@launch
} }
val zone = TimeZone.currentSystemDefault() val zone = TimeZone.currentSystemDefault()
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone) val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone) ?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
// Sections holding data must show even when not in the defaults. // Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields() _revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form _form.value = snapshot.form
@@ -444,12 +469,28 @@ class EventEditViewModel @Inject constructor(
fun setLocation(value: String) = update { it.copy(location = value) } fun setLocation(value: String) = update { it.copy(location = value) }
fun setDescription(value: String) = update { it.copy(description = value) } fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) { fun setAllDay(value: Boolean) {
update { it.copy(isAllDay = value) } // Going all-day drops any pinned zone: the times become bare dates that
// the provider stores UTC-anchored, so a zone could only misrepresent
// them. Coming back out of all-day leaves the event on the device zone,
// which is the same thing a fresh event gets.
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
// The default reminder differs for all-day vs timed; re-apply the // The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded). // type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder() applyDefaultReminder()
} }
/**
* Pin the event to [zoneId], or pass null to follow the device. The zone
* rides along as a recent so the picker can offer it without a search next
* time — only real picks are remembered, not the device fallback.
*/
fun setTimezone(zoneId: String?) {
update { it.copy(timezone = zoneId) }
if (zoneId != null) {
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
}
}
/** /**
* Switching calendars drops any chosen colour: a palette key is * Switching calendars drops any chosen colour: a palette key is
* account-scoped, and a raw colour may be invalid on the new calendar. * account-scoped, and a raw colour may be invalid on the new calendar.
@@ -539,10 +580,15 @@ class EventEditViewModel @Inject constructor(
_saveState.value = SaveUiState.Saved _saveState.value = SaveUiState.Saved
return return
} }
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
// be updated in place) and is inherently whole-series: an occurrence
// can't live in a different calendar than its series, so a move skips the
// scope dialog even for a recurring event.
val movingCalendar = target != null && form.calendarId != target.original.calendarId
// Managed events are a yearly series whose editable fields (reminders, // Managed events are a yearly series whose editable fields (reminders,
// notes, location) live on the series row — never offer the scope dialog, // notes, location) live on the series row — never offer the scope dialog,
// which would split the series into an exception the sync then reverts. // which would split the series into an exception the sync then reverts.
if (target != null && target.original.rrule != null && !current.isManaged) { if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
_saveState.value = SaveUiState.AwaitingScope _saveState.value = SaveUiState.AwaitingScope
return return
} }
@@ -597,6 +643,17 @@ class EventEditViewModel @Inject constructor(
if (target == null) { if (target == null) {
repository.createEvent(form) repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId)) prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
} else if (form.calendarId != target.original.calendarId) {
// Move to the picked calendar (copy+delete), carrying any
// field edits made in the same save. The target becomes the
// last-used calendar, like a create does.
repository.moveEvent(
eventId = target.eventId,
targetCalendarId = requireNotNull(form.calendarId),
original = target.original,
updated = form,
)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
} else { } else {
when (scope) { when (scope) {
RecurringWriteScope.ThisEvent -> RecurringWriteScope.ThisEvent ->

View File

@@ -64,22 +64,26 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
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.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -103,6 +107,7 @@ fun MonthScreen(
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(), viewModel: MonthViewModel = hiltViewModel(),
) { ) {
@@ -128,6 +133,13 @@ fun MonthScreen(
else -> true else -> true
} }
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is MonthUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
@@ -184,16 +196,19 @@ fun MonthScreen(
topBar = { topBar = {
MonthTopBar( MonthTopBar(
month = month, month = month,
currentYear = currentYear,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnCurrentMonth, todayVisible = !isOnCurrentMonth && !todayInToolbar,
todayText = stringResource(R.string.month_today_action), todayText = stringResource(R.string.month_today_action),
onToday = jumpToToday, onToday = jumpToToday,
onCreate = { onCreate = {
@@ -292,16 +307,20 @@ private fun MonthContent(
@Composable @Composable
private fun MonthTopBar( private fun MonthTopBar(
month: YearMonth, month: YearMonth,
currentYear: Int,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale()
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = formatMonthYear(month), text = formatMonthTitle(month, locale, currentYear),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
}, },
@@ -314,6 +333,7 @@ private fun MonthTopBar(
} }
}, },
actions = { actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) { IconButton(onClick = onOpenSearch) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
@@ -380,7 +400,10 @@ private fun MonthGrid(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 4.dp, vertical = 4.dp), // Match the weekday header's 8dp inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
state.weeks.forEach { week -> state.weeks.forEach { week ->
@@ -627,7 +650,7 @@ private fun DayNumberCell(
} }
} }
/** A filled event pill/bar — pastelized fill, title clipped to one line. */ /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
@Composable @Composable
private fun MonthBar( private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance, event: de.jeanlucmakiola.calendula.domain.EventInstance,
@@ -639,6 +662,8 @@ private fun MonthBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val shape = RoundedCornerShape( val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp, topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -647,7 +672,7 @@ private fun MonthBar(
) )
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), shape) .background(fill, shape)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
@@ -657,7 +682,7 @@ private fun MonthBar(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f), color = eventInk(fill),
) )
} }
} }
@@ -670,6 +695,7 @@ private fun OverflowDots(
dark: Boolean, dark: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val soften = LocalSoftenColors.current
Row( Row(
modifier = modifier.height(EVENT_ROW_HEIGHT), modifier = modifier.height(EVENT_ROW_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
@@ -679,7 +705,7 @@ private fun OverflowDots(
Box( Box(
modifier = Modifier modifier = Modifier
.size(6.dp) .size(6.dp)
.background(pastelize(argb, dark), CircleShape), .background(eventFill(argb, dark, soften), CircleShape),
) )
} }
if (extra > 0) { if (extra > 0) {
@@ -724,9 +750,10 @@ private fun MonthGridLoading() {
} }
} }
private fun formatMonthYear(ym: YearMonth): String { private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
val locale = Locale.getDefault() formatCalendarTitle(
val name = java.time.Month.of(ym.month.ordinal + 1) date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
.getDisplayName(JavaTextStyle.FULL, locale) locale = locale,
return "$name ${ym.year}" currentYear = currentYear,
} skeleton = "LLLL",
)

View File

@@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -34,7 +33,6 @@ import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -48,16 +46,9 @@ class MonthViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault()
/** First day of the week, from the Settings preference (AUTO → locale). */ /** First day of the week, from the Settings preference (AUTO → locale). */
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
.map { it.resolveFirstDay(locale) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayOfWeek.MONDAY,
)
/** Whether to fade events that have already finished (display concern only). */ /** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents

View File

@@ -54,10 +54,11 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import java.time.Instant as JavaInstant import java.time.Instant as JavaInstant
import java.time.ZoneId import java.time.ZoneId
@@ -192,6 +193,7 @@ private fun SearchResultRow(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
GroupedRow( GroupedRow(
modifier = modifier, modifier = modifier,
title = event.title, title = event.title,
@@ -203,7 +205,7 @@ private fun SearchResultRow(
modifier = Modifier modifier = Modifier
.size(width = 6.dp, height = 36.dp) .size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)), .background(eventFill(event.color, dark, soften)),
) )
}, },
onClick = onClick, onClick = onClick,

View File

@@ -23,6 +23,8 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -42,8 +44,8 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
@@ -75,6 +77,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
@@ -93,6 +96,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
@@ -131,10 +135,12 @@ import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.theme.BundledFont import de.jeanlucmakiola.calendula.ui.theme.BundledFont
@@ -486,8 +492,10 @@ private fun AppearanceScreen(
var showPastEvents by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) }
var showPlainFont by remember { mutableStateOf(false) } var showPlainFont by remember { mutableStateOf(false) }
var showAppName by remember { mutableStateOf(false) }
val fonts by viewModel.fontState.collectAsStateWithLifecycle() val fonts by viewModel.fontState.collectAsStateWithLifecycle()
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
// A picked file that didn't parse as a font: tell the user and keep the old choice. // A picked file that didn't parse as a font: tell the user and keep the old choice.
val context = LocalContext.current val context = LocalContext.current
val importFailedMessage = stringResource(R.string.settings_font_import_failed) val importFailedMessage = stringResource(R.string.settings_font_import_failed)
@@ -516,7 +524,7 @@ private fun AppearanceScreen(
} else { } else {
stringResource(R.string.settings_dynamic_color_unavailable) stringResource(R.string.settings_dynamic_color_unavailable)
}, },
position = Position.Bottom, position = Position.Middle,
trailing = { trailing = {
Switch( Switch(
checked = state.dynamicColor, checked = state.dynamicColor,
@@ -530,6 +538,18 @@ private fun AppearanceScreen(
null null
}, },
) )
GroupedRow(
title = stringResource(R.string.settings_soften_colors),
summary = stringResource(R.string.settings_soften_colors_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.softenColors,
onCheckedChange = viewModel::setSoftenColors,
)
},
onClick = { viewModel.setSoftenColors(!state.softenColors) },
)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -558,6 +578,18 @@ private fun AppearanceScreen(
position = Position.Top, position = Position.Top,
onClick = { showDefaultView = true }, 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( GroupedRow(
title = stringResource(R.string.settings_week_start), title = stringResource(R.string.settings_week_start),
summary = weekStartLabel(state.weekStart), summary = weekStartLabel(state.weekStart),
@@ -630,6 +662,18 @@ private fun AppearanceScreen(
position = Position.Middle, position = Position.Middle,
onClick = { showAgendaWidgetRange = true }, onClick = { showAgendaWidgetRange = true },
) )
GroupedRow(
title = stringResource(R.string.settings_agenda_show_today),
summary = stringResource(R.string.settings_agenda_show_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.agendaShowToday,
onCheckedChange = viewModel::setAgendaShowToday,
)
},
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
)
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar), title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint), summary = stringResource(R.string.settings_agenda_range_bar_hint),
@@ -642,8 +686,58 @@ private fun AppearanceScreen(
}, },
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) }, onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
) )
Spacer(Modifier.height(16.dp))
// App name — chooses the launcher label between "Calendula" and "Calendar"
// (issue #44). Own group: it's a launcher/system concern, not calendar
// formatting. A sub-page chooser (not a switch), matching the app's other
// "choose one" settings and leaving room for more names later.
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = launcherNameLabel(launcherName),
position = Position.Alone,
onClick = { showAppName = true },
)
} }
if (showAppName) {
FullScreenPicker(
title = stringResource(R.string.settings_app_name),
onDismiss = { showAppName = false },
predictiveBack = true,
) {
// Show both names as launcher-mark previews so the user sees what
// they'd switch to, not just the current state. Tapping applies
// immediately and highlights — the picker stays open so the change is
// visible; back exits.
Text(
text = stringResource(R.string.settings_app_name_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
LauncherName.entries.forEach { option ->
AppNameOptionCard(
name = option,
selected = launcherName == option,
onClick = { viewModel.setLauncherName(option) },
modifier = Modifier.weight(1f),
)
}
}
}
}
if (showTheme) { if (showTheme) {
OptionPicker( OptionPicker(
title = stringResource(R.string.settings_theme), title = stringResource(R.string.settings_theme),
@@ -1056,13 +1150,7 @@ private fun NotificationsScreen(
}, },
position = Position.Top, position = Position.Top,
trailing = if (batteryExempt) { trailing = if (batteryExempt) {
{ { SelectedCheck() }
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else { } else {
null null
}, },
@@ -1174,10 +1262,9 @@ private fun NotificationsScreen(
} }
if (showSnooze) { if (showSnooze) {
OptionPicker( SnoozeDurationPicker(
title = stringResource(R.string.settings_snooze_duration), title = stringResource(R.string.settings_snooze_duration),
predictiveBack = true, presets = SNOOZE_PRESETS,
options = SNOOZE_PRESETS,
selected = state.snoozeMinutes, selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) }, label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) }, onSelect = { viewModel.setSnoozeMinutes(it) },
@@ -1668,6 +1755,98 @@ private fun openUrl(context: Context, url: String) {
runCatching { context.startActivity(intent) } runCatching { context.startActivity(intent) }
} }
/** The display name for a launcher-label choice (issue #44). */
@Composable
private fun launcherNameLabel(name: LauncherName): String = stringResource(
when (name) {
LauncherName.CALENDULA -> R.string.app_name
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
},
)
/**
* One selectable launcher-name preview in the App name picker (issue #44): the
* app's launcher mark over the name, framed as a card. The active one carries a
* primary border, a tinted container and a check; tapping selects it. The mark
* is the same for both — only the label changes — so the card previews exactly
* what the home screen will read.
*/
@Composable
private fun AppNameOptionCard(
name: LauncherName,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(24.dp)
val borderColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
}
val containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
Column(
modifier = modifier
.clip(shape)
.background(containerColor)
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
.clickable(onClick = onClick)
.padding(vertical = 20.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// The adaptive launcher mark, reconstructed as a squircle (as in the
// onboarding BrandHero) so it renders identically everywhere.
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(18.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
Text(
text = launcherNameLabel(name),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
)
// Selection indicator: a filled check when active, an empty ring otherwise.
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
.then(
if (selected) {
Modifier
} else {
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
},
),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp),
)
}
}
}
}
@Composable @Composable
private fun themeLabel(mode: ThemeMode): String = stringResource( private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) { when (mode) {
@@ -1809,13 +1988,7 @@ private fun FontOptionRow(
} }
}, },
trailing = if (selected) { trailing = if (selected) {
{ { SelectedCheck() }
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else { } else {
null null
}, },

View File

@@ -24,6 +24,8 @@ data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM, val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true, val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true, val dynamicColorAvailable: Boolean = true,
/** Whether raw provider colours are softened to theme-fitting pastels (#36). */
val softenColors: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.Auto, val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */ /** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
@@ -35,10 +37,14 @@ data class SettingsUiState(
val dimCompletedEvents: Boolean = false, val dimCompletedEvents: Boolean = false,
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */ /** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
val showWeekNumbers: Boolean = false, 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). */ /** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month, val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */ /** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month, val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */ /** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true, val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */ /** The calendar view the app opens on, and the home of the view back stack (M1). */

View File

@@ -10,6 +10,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
@@ -37,12 +39,15 @@ import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -61,6 +66,7 @@ class SettingsViewModel @Inject constructor(
repository: CalendarRepository, repository: CalendarRepository,
private val specialDatesEngine: SpecialDatesSyncEngine, private val specialDatesEngine: SpecialDatesSyncEngine,
specialDatesSpec: SpecialDatesCalendarSpec, specialDatesSpec: SpecialDatesCalendarSpec,
private val launcherNameManager: LauncherNameManager,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
) : ViewModel() { ) : ViewModel() {
@@ -116,14 +122,25 @@ class SettingsViewModel @Inject constructor(
prefs.agendaScreenRange, prefs.agendaScreenRange,
prefs.agendaWidgetRange, prefs.agendaWidgetRange,
prefs.timeFormat, prefs.timeFormat,
// Two grid-display toggles folded into one flow so they fit this // Display toggles folded into one flow so they fit this group —
// group — the outer combine is already at its five-arg limit. // the outer combine is already at its five-arg limit.
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair), combine(
) { view, screenRange, widgetRange, timeFormat, gridToggles -> prefs.showHourLines,
prefs.showWeekNumbers,
prefs.agendaShowToday,
prefs.softenCalendarColors,
prefs.todayButtonInToolbar,
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings( ViewSettings(
view, screenRange, widgetRange, timeFormat, view, screenRange, widgetRange, timeFormat,
showHourLines = gridToggles.first, showHourLines = toggles.showHourLines,
showWeekNumbers = gridToggles.second, showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar,
) )
}, },
combine( combine(
@@ -147,6 +164,9 @@ class SettingsViewModel @Inject constructor(
timeFormat = views.timeFormat, timeFormat = views.timeFormat,
showHourLines = views.showHourLines, showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers, showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar,
agendaShowRangeBar = misc.showRangeBar, agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle, autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay, pastEventDisplay = misc.pastEventDisplay,
@@ -187,6 +207,28 @@ class SettingsViewModel @Inject constructor(
initialValue = AppFontSettings(), initialValue = AppFontSettings(),
) )
/**
* The launcher-label choice (issue #44). Backed by the manifest aliases'
* component-enabled state rather than a stored preference, so it's read
* imperatively via [LauncherNameManager] and held here in its own flow — the
* main settings combine is already at its arity limit and this isn't a
* DataStore flow anyway.
*
* Seeded with the manifest default and corrected off-thread rather than read
* in the initializer: `getComponentEnabledSetting` is a binder round-trip to
* system_server, and this ViewModel is constructed on the main thread while
* Settings is opening. The row it feeds is well below the fold, so the
* one-frame correction is never visible.
*/
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
init {
viewModelScope.launch {
_launcherName.value = withContext(io) { launcherNameManager.current() }
}
}
// Emitted when a picked font file couldn't be read as a font; the screen // Emitted when a picked font file couldn't be read as a font; the screen
// surfaces it and the previous selection stays put. // surfaces it and the previous selection stays put.
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
@@ -220,6 +262,17 @@ class SettingsViewModel @Inject constructor(
val timeFormat: TimeFormatPref, val timeFormat: TimeFormatPref,
val showHourLines: Boolean, val showHourLines: Boolean,
val showWeekNumbers: Boolean, val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
)
private data class DisplayToggles(
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
) )
private data class MiscSettings( private data class MiscSettings(
@@ -332,6 +385,19 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDynamicColor(enabled) } viewModelScope.launch { prefs.setDynamicColor(enabled) }
} }
fun setSoftenColors(enabled: Boolean) {
viewModelScope.launch {
prefs.setSoftenCalendarColors(enabled)
// Both widgets paint event colours through the same softener, so a
// change has to redraw them too (they read the flag in their data
// preamble, like is24Hour).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setFont(role: FontRole, token: String) { fun setFont(role: FontRole, token: String) {
viewModelScope.launch { viewModelScope.launch {
when (role) { when (role) {
@@ -398,6 +464,22 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) } viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
} }
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)
// The agenda widget reads this reactively, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setTimeFormat(pref: TimeFormatPref) { fun setTimeFormat(pref: TimeFormatPref) {
viewModelScope.launch { prefs.setTimeFormat(pref) } viewModelScope.launch { prefs.setTimeFormat(pref) }
} }
@@ -410,6 +492,26 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) } viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
} }
fun setTodayButtonInToolbar(enabled: Boolean) {
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
}
/**
* Switch the launcher label between "Calendula" and "Calendar" (issue #44).
* The card highlights immediately, then settles on whatever the component
* state actually reports — the two `setComponentEnabledSetting` calls are
* binder round-trips, so they don't belong on the main thread either.
*/
fun setLauncherName(name: LauncherName) {
_launcherName.value = name
viewModelScope.launch {
_launcherName.value = withContext(io) {
launcherNameManager.set(name)
launcherNameManager.current()
}
}
}
fun setPastEventDisplay(mode: PastEventDisplay) { fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch { viewModelScope.launch {
prefs.setPastEventDisplay(mode) prefs.setPastEventDisplay(mode)

View File

@@ -77,20 +77,25 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
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.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
@@ -98,7 +103,6 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -114,6 +118,10 @@ import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's content (week badge + hour labels) so it centres
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
* (the app bar's 4dp inset + 24dp half icon button). */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val MIN_EVENT_HEIGHT = 24.dp private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -137,6 +145,7 @@ fun WeekScreen(
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(), viewModel: WeekViewModel = hiltViewModel(),
) { ) {
@@ -174,6 +183,13 @@ fun WeekScreen(
else -> true else -> true
} }
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is WeekUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump. // Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() } val goNext = { slideDir = 1; viewModel.goToNext() }
@@ -222,16 +238,19 @@ fun WeekScreen(
topBar = { topBar = {
WeekTopBar( WeekTopBar(
weekStart = weekStart, weekStart = weekStart,
currentYear = currentYear,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnCurrentWeek, todayVisible = !isOnCurrentWeek && !todayInToolbar,
todayText = stringResource(R.string.week_today_action), todayText = stringResource(R.string.week_today_action),
onToday = jumpToToday, onToday = jumpToToday,
onCreate = { onCreate = {
@@ -389,16 +408,20 @@ private fun WeekSuccess(
@Composable @Composable
private fun WeekTopBar( private fun WeekTopBar(
weekStart: LocalDate, weekStart: LocalDate,
currentYear: Int,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale()
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = formatWeekRange(weekStart), text = formatWeekTitle(weekStart, locale, currentYear),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
}, },
@@ -411,6 +434,7 @@ private fun WeekTopBar(
} }
}, },
actions = { actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) { IconButton(onClick = onOpenSearch) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
@@ -448,9 +472,10 @@ private fun WeekDayHeader(
.padding(top = 4.dp, bottom = 8.dp), .padding(top = 4.dp, bottom = 8.dp),
) { ) {
// Mirror the day-column layout (empty weekday line + spacer) so the // Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers. // badge lines up vertically with the date numbers. The start inset centres
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
Column( Column(
modifier = Modifier.width(GUTTER_WIDTH), modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = " ", style = MaterialTheme.typography.labelSmall) Text(text = " ", style = MaterialTheme.typography.labelSmall)
@@ -584,9 +609,11 @@ private fun AllDayBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -597,7 +624,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f), color = eventInk(fill),
) )
} }
} }
@@ -621,10 +648,12 @@ private fun Timeline(
// soft corners are permanent at any scroll position (not just at the // soft corners are permanent at any scroll position (not just at the
// day's start/end). // day's start/end).
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day columns) // Hour gutter (scrolls in sync with the day columns). Same start inset
// as the header badge so the labels sit under it and on the hamburger.
Column( Column(
modifier = Modifier modifier = Modifier
.width(GUTTER_WIDTH) .width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight() .fillMaxHeight()
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {
@@ -774,9 +803,11 @@ private fun EventBlock(
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" }, .semantics { contentDescription = "$title, $timeLabel" },
@@ -787,7 +818,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines, maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
) )
if (showTime) { if (showTime) {
Text( Text(
@@ -795,7 +826,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f), color = eventInk(fill, alpha = 0.6f),
) )
} }
} }
@@ -841,18 +872,29 @@ private fun WeekLoading() {
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String = private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
formatMinuteOfDay(min, is24Hour, locale) formatMinuteOfDay(min, is24Hour, locale)
private fun formatWeekRange(weekStart: LocalDate): String { /**
val locale = Locale.getDefault() * The week's title: just the month [weekStart] falls in.
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) *
val monthName = { d: LocalDate -> * The day numbers are already in the column headers directly below, so spelling
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale) * out "13.19. Jul" restates them in the widest string in the bar. Naming the
} * month of [weekStart] means a week straddling a boundary keeps the outgoing
return if (weekStart.month == end.month && weekStart.year == end.year) { * month until it is fully gone — a week is seven contiguous days, so the earlier
"${weekStart.day}.${end.day}. ${monthName(weekStart)} ${weekStart.year}" * month has a day in it exactly while [weekStart] is still inside it. That also
} else if (weekStart.year == end.year) { * makes the *month* depend on nothing but [weekStart], so it cannot drift with
"${weekStart.day}. ${monthName(weekStart)} ${end.day}. ${monthName(end)} ${end.year}" * the direction you paged in from.
} else { *
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} " + * The *year* is decided from the whole week, not just its start: a week running
"${end.day}. ${monthName(end)} ${end.year}" * Dec 28 Jan 3 has four of its seven visible columns in the new year, so
} * "December" with no year would be actively misleading while the reader is
* looking straight at January dates.
*/
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = "LLLL",
forceYear = weekEnd.year != weekStart.year,
)
} }

View File

@@ -60,12 +60,20 @@ sealed interface AgendaWidgetData {
val days: List<AgendaDay>, val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */ /** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean, val is24Hour: Boolean,
/** Whether event colours are softened to pastels, or shown raw (#36). */
val soften: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */ /** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */ /** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange, val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */ /** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay, val savedPastDisplay: PastEventDisplay,
/**
* Saved "always show today" pref (#35) — anchors today at the top with a
* placeholder even when it has no events left. The fallback read reactively
* from Glance state before an instance has its own state set.
*/
val savedShowToday: Boolean,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */ /** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant, val now: Instant,
) : AgendaWidgetData ) : AgendaWidgetData
@@ -117,21 +125,26 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val prefs = ep.settingsPrefs() val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first() val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first() val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in // Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain // the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting. // recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone) 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 instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first() val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this)) .is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready( return AgendaWidgetData.Ready(
today = anchor, today = anchor,
days = groupAgendaDays(anchor, instances, zone), days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = is24Hour, is24Hour = is24Hour,
soften = soften,
weekStart = weekStart, weekStart = weekStart,
savedRange = savedRange, savedRange = savedRange,
savedPastDisplay = savedPastDisplay, savedPastDisplay = savedPastDisplay,
savedShowToday = showToday,
now = Clock.System.now(), now = Clock.System.now(),
) )
} }

View File

@@ -0,0 +1,70 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
/**
* Size tiers a widget scales its typography and metrics across (#51).
*
* Shared by every Glance widget so the bucketing rule can't drift between them:
* each widget keeps its own metrics table, but they all agree on *when* a widget
* counts as compact, regular, large or extra-large. Both widgets already declare
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
* size via `LocalSize.current` and passes it to [scaleFor].
*
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
* covered by plain JVM tests.
*/
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
/**
* Chrome a widget spends before its first content row: outer vertical padding
* plus a header row and its spacer. Subtracted from the raw height so the height
* thresholds below talk about *usable* space rather than gross widget height.
*/
private val CHROME_HEIGHT = 60.dp
/**
* Buckets a live widget size into a [WidgetScale] by **width**.
*
* Width is the right axis: it governs how much of a title fits on a row, so it's
* what should drive type size. Height only decides how many rows are visible — a
* tall, narrow widget wants *more events*, not bigger text — so it never raises
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
* stepped back down so it can't keep oversized type in a sliver of space.
*
* The width thresholds are spread across the range a phone can actually produce
* (~180dp up to roughly the screen width) rather than over a theoretical range,
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
* surfaces — tablets, foldables, landscape — where the extra size reads well.
*
* The height cap is deliberately generous: it exists to catch a widget squashed
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
* the usual three cells tall (~270dp) must still reach the tier its width earned
* — that is exactly the resize #51 reports, and an aggressive cap would make the
* whole feature a no-op for it.
*/
internal fun scaleFor(size: DpSize): WidgetScale {
val byWidth = when {
size.width < 260.dp -> WidgetScale.COMPACT
size.width < 330.dp -> WidgetScale.REGULAR
size.width < 420.dp -> WidgetScale.LARGE
else -> WidgetScale.XLARGE
}
// Height can only ever pull the tier *down*, never push it up: a squashed
// widget would otherwise keep the big type its width earned and look absurd
// in the little space left. Keeping this a cap (rather than a second scaling
// axis) is what preserves "tall and narrow shows more events, not bigger
// text". Thresholds are usable height — roughly one, two and three rows of
// breathing room once the header is paid for.
val usable = size.height - CHROME_HEIGHT
val heightCap = when {
usable < 70.dp -> WidgetScale.COMPACT
usable < 130.dp -> WidgetScale.REGULAR
usable < 200.dp -> WidgetScale.LARGE
else -> WidgetScale.XLARGE
}
return minOf(byWidth, heightCap)
}

View File

@@ -0,0 +1,150 @@
package de.jeanlucmakiola.calendula.widget.agenda
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.widget.WidgetScale
/**
* Horizontal layout constants for an agenda event row. These don't scale with the
* tier — a wider stripe or gap would eat title width, which is the thing the row
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and
* the "nothing left today" line can never drift out of alignment with the event
* title column the way a hardcoded 19dp could.
*/
internal val ROW_H_PAD = 4.dp
internal val STRIPE_WIDTH = 5.dp
internal val STRIPE_GAP = 10.dp
/** Left indent that lines non-event text up with the event title column. */
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
/**
* The width band a *default* agenda placement can land in, per
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
* launcher cell grids vary, so the whole band — not one measured point — has to
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
* to hold. A test pins that.
*
* If the provider's `targetCellWidth` ever changes, this band and the first
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
* revisited together.
*/
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
/**
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
* grow (the horizontal row constants above, corner radii) stay constants rather
* than routing through here.
*/
internal data class AgendaMetrics(
val title: TextUnit, // header "Upcoming"
val dayHeader: TextUnit, // day label ("Today · …")
val eventTitle: TextUnit, // event title line
val eventTime: TextUnit, // time/location line
val placeholder: TextUnit, // "no more events today" (#35)
val message: TextUnit, // empty/permission centred message
val stripeH: Dp, // coloured event stripe height
val iconImage: Dp, // header action icon glyph
val iconBox: Dp, // header action touch target
val rowVPad: Dp, // event row vertical padding
val dayHeaderTopPad: Dp, // space above a day header
) {
/**
* Resolves the stripe height against the user's system font scale.
*
* The stripe is a [Dp] but the two text lines it sits beside are `sp`, so
* they scale with the accessibility font setting and the stripe does not —
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
* it is supposed to mark. Multiplying by the same factor keeps them locked,
* and at the default scale of 1.0 reproduces the tier's value exactly.
*/
fun scaledForFont(fontScale: Float): AgendaMetrics =
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
}
/*
* Type sizes are anchored to the Material 3 type scale (see the `material-3`
* skill's typography reference) rather than invented: 16sp is Title Medium, 14sp
* Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large.
*
* Two documented deviations:
*
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
* COMPACT reproduces the widget's original constants verbatim so a
* default-sized widget looks exactly as it did (#51), and snapping it to
* Title Small (14sp) would break that promise for a 1sp gain.
*
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
* which is far too coarse for four widget tiers. Where a role would force a
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
* mark it. The endpoints stay on real roles.
*/
private val COMPACT_METRICS = AgendaMetrics(
title = 16.sp, // M3 Title Medium
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
eventTitle = 14.sp, // M3 Body Medium
eventTime = 12.sp, // M3 Body Small
placeholder = 14.sp, // M3 Body Medium
message = 14.sp, // M3 Body Medium
stripeH = 36.dp,
iconImage = 22.dp,
iconBox = 40.dp,
rowVPad = 4.dp,
dayHeaderTopPad = 10.dp,
)
private val REGULAR_METRICS = AgendaMetrics(
title = 18.sp, // †
dayHeader = 14.sp, // M3 Title Small
eventTitle = 16.sp, // M3 Body Large
eventTime = 14.sp, // M3 Body Medium
placeholder = 16.sp, // M3 Body Large
message = 16.sp, // M3 Body Large
stripeH = 40.dp,
iconImage = 24.dp,
iconBox = 44.dp,
rowVPad = 5.dp,
dayHeaderTopPad = 11.dp,
)
private val LARGE_METRICS = AgendaMetrics(
title = 20.sp, // †
dayHeader = 16.sp, // M3 Title Medium
eventTitle = 18.sp, // †
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
placeholder = 18.sp, // † on purpose, so the title keeps its
message = 18.sp, // † lead and the hierarchy survives.
stripeH = 46.dp,
iconImage = 26.dp,
iconBox = 48.dp,
rowVPad = 6.dp,
dayHeaderTopPad = 12.dp,
)
private val XLARGE_METRICS = AgendaMetrics(
title = 22.sp, // M3 Title Large
dayHeader = 18.sp, // †
eventTitle = 20.sp, // †
eventTime = 16.sp, // M3 Body Large
placeholder = 20.sp, // †
message = 20.sp, // †
stripeH = 52.dp,
iconImage = 28.dp,
iconBox = 52.dp,
rowVPad = 8.dp,
dayHeaderTopPad = 14.dp,
)
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
private val AGENDA_METRICS = listOf(
COMPACT_METRICS,
REGULAR_METRICS,
LARGE_METRICS,
XLARGE_METRICS,
)
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]

View File

@@ -5,7 +5,7 @@ import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
import androidx.glance.GlanceId import androidx.glance.GlanceId
@@ -13,9 +13,11 @@ import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme import androidx.glance.GlanceTheme
import androidx.glance.Image import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.action.actionStartActivity
@@ -48,14 +50,19 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange 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.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.scaleFor
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
@@ -65,7 +72,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
/** /**
@@ -91,10 +97,31 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
*/ */
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display") internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
/**
* Per-instance Glance state key holding the "always show today" toggle (#35).
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
* so toggling the setting reflects on the live widget without depending on the
* `provideGlance` preamble re-running.
*/
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
class AgendaWidget : GlanceAppWidget() { class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
// Exact so the composition sees the widget's live size and can scale type/rows
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
// same.
//
// Note Exact still asks Glance for one RemoteViews per host size (typically
// portrait + landscape) where the old SizeMode.Single produced exactly one —
// so the serialized payload roughly doubles. That is why the row list below is
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
// = 365) could otherwise push the RemoteViews past the binder transaction
// limit and the host would just show "Problem loading widget".
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData() val data = context.loadAgendaWidgetData()
val dark = (context.resources.configuration.uiMode and val dark = (context.resources.configuration.uiMode and
@@ -114,24 +141,41 @@ class RefreshAgendaAction : ActionCallback {
} }
} }
/**
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
* stays well inside the binder transaction limit regardless of range and calendar
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
* user scrolling a home-screen widget past a hundred rows is not a case worth
* risking a failed update for.
*/
private const val MAX_AGENDA_ROWS = 100
/** Flat row model so the [LazyColumn] can mix day headers and events. */ /** Flat row model so the [LazyColumn] can mix day headers and events. */
private sealed interface AgendaRow { private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : 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
} }
@Composable @Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
// The stripe is then re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings.
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
Column( Column(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
.background(GlanceTheme.colors.surface) .background(GlanceTheme.colors.surface)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
) { ) {
AgendaHeader() AgendaHeader(metrics)
Spacer(GlanceModifier.height(4.dp)) Spacer(GlanceModifier.height(4.dp))
when (data) { when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> { is AgendaWidgetData.Ready -> {
// Range read reactively from per-instance Glance state (falls back // Range read reactively from per-instance Glance state (falls back
// to the saved pref for a freshly placed widget), then the wide // to the saved pref for a freshly placed widget), then the wide
@@ -139,43 +183,62 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay = val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
val rangeEnd = data.today.plus( val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1, range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
// Slice to the chosen range, then drop finished events (and any day // Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is. // they leave empty) when the mode is Hide — so "Upcoming" really is.
val visibleDays = data.days // Finally re-anchor today (as an empty placeholder day) when the
.filter { it.date <= rangeEnd } // "always show today" pref is on and today has no events left (#35).
.let { days -> val visibleDays = anchorTodayIfMissing(
if (pastDisplay == PastEventDisplay.HIDE) { days = data.days
days.mapNotNull { day -> .filter { it.date <= rangeEnd }
val remaining = day.events.filterNot { it.hasEnded(data.now) } .let { days ->
if (remaining.isEmpty()) null else day.copy(events = remaining) if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(data.now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
days
} }
} else { },
days today = data.today,
} enabled = showToday,
} )
if (visibleDays.isEmpty()) { if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title) WidgetMessage(R.string.agenda_empty_title, metrics)
} else { } else {
val rows = buildList { val rows = buildList {
visibleDays.forEach { day -> visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today)) add(AgendaRow.Header(day.date, data.today))
day.events.forEach { add(AgendaRow.Event(it)) } if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
}
} }
} }
// Bound the payload, then drop a day header the cut left
// stranded with nothing under it.
.take(MAX_AGENDA_ROWS)
.dropLastWhile { it is AgendaRow.Header }
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index -> items(rows.size) { index ->
when (val row = rows[index]) { when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today) is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
is AgendaRow.Event -> EventRow( is AgendaRow.Event -> EventRow(
event = row.event, event = row.event,
day = row.date,
dark = dark, dark = dark,
soften = data.soften,
is24Hour = data.is24Hour, is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM && dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now), row.event.hasEnded(data.now),
metrics = metrics,
) )
} }
} }
@@ -187,7 +250,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
} }
@Composable @Composable
private fun AgendaHeader() { private fun AgendaHeader(metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Row( Row(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
@@ -199,7 +262,7 @@ private fun AgendaHeader() {
text = context.getString(R.string.widget_agenda_title), text = context.getString(R.string.widget_agenda_title),
style = TextStyle( style = TextStyle(
color = GlanceTheme.colors.primary, color = GlanceTheme.colors.primary,
fontSize = 16.sp, fontSize = metrics.title,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
modifier = GlanceModifier modifier = GlanceModifier
@@ -212,6 +275,7 @@ private fun AgendaHeader() {
resId = R.drawable.ic_widget_refresh, resId = R.drawable.ic_widget_refresh,
contentDescription = context.getString(R.string.widget_refresh), contentDescription = context.getString(R.string.widget_refresh),
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()), onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
metrics = metrics,
) )
IconButton( IconButton(
resId = R.drawable.ic_widget_add, resId = R.drawable.ic_widget_add,
@@ -221,39 +285,77 @@ private fun AgendaHeader() {
MainActivity.openCreateIntent(context, today(systemZone())), MainActivity.openCreateIntent(context, today(systemZone())),
), ),
), ),
metrics = metrics,
) )
} }
} }
@Composable @Composable
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) { private fun IconButton(
resId: Int,
contentDescription: String,
onClick: GlanceModifier,
metrics: AgendaMetrics,
) {
Box( Box(
modifier = GlanceModifier.size(40.dp).then(onClick), modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Image( Image(
provider = ImageProvider(resId), provider = ImageProvider(resId),
contentDescription = contentDescription, contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(22.dp), modifier = GlanceModifier.size(metrics.iconImage),
) )
} }
} }
@Composable @Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate) { private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Text( Text(
text = agendaDayLabel(context, date, today), text = agendaDayLabel(context, date, today),
style = TextStyle( style = TextStyle(
color = if (date == today) GlanceTheme.colors.primary color = if (date == today) GlanceTheme.colors.primary
else GlanceTheme.colors.onSurfaceVariant, else GlanceTheme.colors.onSurfaceVariant,
fontSize = 13.sp, fontSize = metrics.dayHeader,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp) .padding(
start = 8.dp,
end = 8.dp,
top = metrics.dayHeaderTopPad,
bottom = metrics.rowVPad,
)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
/**
* "No more events today" under an anchored, event-less today (#35). Kept plain —
* a muted line indented to the event titles — since the widget's event rows are
* plain too (a stripe + text, not cards), so a card here would look out of place.
*/
@Composable
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Text(
text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
modifier = GlanceModifier
.fillMaxWidth()
.padding(
start = TEXT_INDENT,
end = 8.dp,
top = 2.dp,
bottom = metrics.rowVPad + 2.dp,
)
.clickable( .clickable(
actionStartActivity( actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda), MainActivity.openDateIntent(context, date, CalendarView.Agenda),
@@ -263,19 +365,27 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
} }
@Composable @Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) { private fun EventRow(
event: EventInstance,
day: LocalDate,
dark: Boolean,
soften: Boolean,
is24Hour: Boolean,
dimmed: Boolean,
metrics: AgendaMetrics,
) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) } val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and // Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone. // dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = pastelize(event.color, dark).let { val stripeColor = eventFill(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it if (dimmed) it.copy(alpha = EventDimAlpha) else it
} }
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row( Row(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp) .padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
.clickable( .clickable(
actionStartActivity( actionStartActivity(
MainActivity.openEventIntent( MainActivity.openEventIntent(
@@ -291,29 +401,29 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dim
) { ) {
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(5.dp) .width(STRIPE_WIDTH)
.height(36.dp) .height(metrics.stripeH)
.cornerRadius(3.dp) .cornerRadius(3.dp)
.background(stripeColor), .background(stripeColor),
) {} ) {}
Spacer(GlanceModifier.width(10.dp)) Spacer(GlanceModifier.width(STRIPE_GAP))
Column(modifier = GlanceModifier.defaultWeight()) { Column(modifier = GlanceModifier.defaultWeight()) {
Text( Text(
text = title, text = title,
maxLines = 1, maxLines = 1,
style = TextStyle(color = titleColor, fontSize = 14.sp), style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
) )
Text( Text(
text = eventTimeSummary(context, event, is24Hour), text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
) )
} }
} }
} }
@Composable @Composable
private fun WidgetMessage(resId: Int) { private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Box( Box(
modifier = GlanceModifier.fillMaxSize().padding(16.dp), modifier = GlanceModifier.fillMaxSize().padding(16.dp),
@@ -321,7 +431,7 @@ private fun WidgetMessage(resId: Int) {
) { ) {
Text( Text(
text = context.getString(resId), text = context.getString(resId),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
) )
} }
} }
@@ -337,17 +447,26 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
} }
val locale = Locale.getDefault() val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) // Weekday + date in the locale's own field order (compact: no year), rather
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) // than a hardcoded day-month layout.
val formatted = "$weekday, ${date.day} $monthName" val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
return if (relative != null) "$relative · $formatted" else formatted return if (relative != null) "$relative · $formatted" else formatted
} }
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String { private fun eventTimeSummary(
val time = if (event.isAllDay) { context: Context,
context.getString(R.string.event_detail_all_day) event: EventInstance,
} else { day: LocalDate,
"${formatTime(event.start, is24Hour)} ${formatTime(event.end, is24Hour)}" 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() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time

View File

@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget.month
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
@@ -50,14 +49,18 @@ import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import kotlinx.coroutines.flow.first
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -76,9 +79,6 @@ private val LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.dp private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp private val GRID_HPADDING = 8.dp
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
private val EventInk = ColorProvider(Color(0xDE000000))
private fun currentMonthIndex(zone: TimeZone): Int { private fun currentMonthIndex(zone: TimeZone): Int {
val t = today(zone) val t = today(zone)
return t.year * 12 + t.month.ordinal return t.year * 12 + t.month.ordinal
@@ -107,9 +107,12 @@ class MonthWidget : GlanceAppWidget() {
val source = context.loadMonthWidgetSource() val source = context.loadMonthWidgetSource()
val dark = (context.resources.configuration.uiMode and val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
// Read fresh (not through the cached source) so toggling the softener
// redraws with the new choice; it's one cheap DataStore read.
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
provideContent { provideContent {
CalendulaGlanceTheme { CalendulaGlanceTheme {
MonthWidgetBody(source = source, dark = dark) MonthWidgetBody(source = source, dark = dark, soften = soften)
} }
} }
} }
@@ -140,7 +143,7 @@ class ResetMonthAction : ActionCallback {
} }
@Composable @Composable
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) { private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
Column( Column(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
@@ -160,7 +163,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
MonthHeader(label = monthLabel(ym)) MonthHeader(label = monthLabel(ym, source.today.year))
Spacer(GlanceModifier.height(2.dp)) Spacer(GlanceModifier.height(2.dp))
WeekdayHeader(weekStart = source.weekStart, colW = colW) WeekdayHeader(weekStart = source.weekStart, colW = colW)
weeks.forEach { week -> weeks.forEach { week ->
@@ -169,6 +172,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
currentMonth = ym.month, currentMonth = ym.month,
today = source.today, today = source.today,
dark = dark, dark = dark,
soften = soften,
colW = colW, colW = colW,
modifier = GlanceModifier.defaultWeight(), modifier = GlanceModifier.defaultWeight(),
) )
@@ -278,6 +282,7 @@ private fun WeekRow(
currentMonth: Month, currentMonth: Month,
today: LocalDate, today: LocalDate,
dark: Boolean, dark: Boolean,
soften: Boolean,
colW: Dp, colW: Dp,
modifier: GlanceModifier, modifier: GlanceModifier,
) { ) {
@@ -297,7 +302,7 @@ private fun WeekRow(
// One lane row per event row. A multi-day span is a single Box spanning // One lane row per event row. A multi-day span is a single Box spanning
// its columns (colW * n) so it's connected with no seam and rounded ends. // its columns (colW * n) so it's connected with no seam and rounded ends.
repeat(MAX_LANES) { lane -> repeat(MAX_LANES) { lane ->
LaneRow(week = week, lane = lane, dark = dark, colW = colW) LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp)) Spacer(GlanceModifier.height(1.dp))
} }
OverflowRow(week = week, colW = colW) OverflowRow(week = week, colW = colW)
@@ -346,7 +351,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
} }
@Composable @Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
val context = LocalContext.current val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0 var col = 0
@@ -354,12 +359,12 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
if (span != null) { if (span != null) {
val cols = span.endCol - col + 1 val cols = span.endCol - col + 1
SpanBar(event = span.event, dark = dark, width = colW * cols) SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
col = span.endCol + 1 col = span.endCol + 1
} else { } else {
val timed = timedEventAt(week, lane, col, week.days[col]) val timed = timedEventAt(week, lane, col, week.days[col])
if (timed != null) { if (timed != null) {
SpanBar(event = timed, dark = dark, width = colW) SpanBar(event = timed, dark = dark, soften = soften, width = colW)
} else { } else {
// Empty lane cell: a tap opens that day, so blank space in a // Empty lane cell: a tap opens that day, so blank space in a
// day column is a day-open target just like the number is. // day column is a day-open target just like the number is.
@@ -378,8 +383,9 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
/** A single connected, rounded event bar [width] wide with its clipped title. */ /** A single connected, rounded event bar [width] wide with its clipped title. */
@Composable @Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) { private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
val context = LocalContext.current val context = LocalContext.current
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(width) .width(width)
@@ -402,13 +408,13 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
.cornerRadius(4.dp) .cornerRadius(4.dp)
.background(pastelize(event.color, dark)), .background(fill),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
Text( Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) }, text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1, maxLines = 1,
style = TextStyle(color = EventInk, fontSize = 9.sp), style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
modifier = GlanceModifier.padding(horizontal = 3.dp), modifier = GlanceModifier.padding(horizontal = 3.dp),
) )
} }
@@ -475,9 +481,13 @@ private fun PermissionMessage() {
} }
} }
private fun monthLabel(month: YearMonth): String { // Locale.getDefault() rather than currentLocale(): Glance has no
val locale = Locale.getDefault() // LocalConfiguration, and the widget is re-rendered on a configuration change
val name = java.time.Month.of(month.month.ordinal + 1) // anyway (same convention as AgendaWidget).
.getDisplayName(JavaTextStyle.FULL, locale) private fun monthLabel(month: YearMonth, currentYear: Int): String =
return "$name ${month.year}" formatCalendarTitle(
} date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
locale = Locale.getDefault(),
currentYear = currentYear,
skeleton = "LLLL",
)

View File

@@ -403,15 +403,15 @@
<string name="shortcut_new_event_long">Crear un nuevo evento</string> <string name="shortcut_new_event_long">Crear un nuevo evento</string>
<string name="settings_qs_tile">Añadir mosaico de ajustes rápidos</string> <string name="settings_qs_tile">Añadir mosaico de ajustes rápidos</string>
<string name="settings_qs_tile_hint">Añadir un recuadro de \"nuevo evento\" al panel de ajustes rápidos.</string> <string name="settings_qs_tile_hint">Añadir un recuadro de \"nuevo evento\" al panel de ajustes rápidos.</string>
<string name="crash_dialog_title">Calendula ha fallado</string> <string name="crash_dialog_title">%1$s ha fallado</string>
<string name="crash_dialog_message">Calendula se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string> <string name="crash_dialog_message">%1$s se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
<string name="crash_dialog_report">Reportar</string> <string name="crash_dialog_report">Reportar</string>
<string name="crash_dialog_dismiss">No por ahora</string> <string name="crash_dialog_dismiss">No por ahora</string>
<string name="crash_report_issue_title">Reporte de error</string> <string name="crash_report_issue_title">Reporte de error</string>
<string name="crash_report_clip_label">Reporte de error de Calendula</string> <string name="crash_report_clip_label">Reporte de error de %1$s</string>
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string> <string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string> <string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
<string name="crash_report_body_template">Gracias por reportar un error en Calendula. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%1$s\n</string> <string name="crash_report_body_template">Gracias por reportar un error en %1$s. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%2$s\n</string>
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string> <string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
<string name="event_attendee_resource">Recurso</string> <string name="event_attendee_resource">Recurso</string>
<string name="settings_section_about">Acerca de</string> <string name="settings_section_about">Acerca de</string>
@@ -466,4 +466,31 @@
<string name="settings_special_dates_grant">Permitir acceso</string> <string name="settings_special_dates_grant">Permitir acceso</string>
<string name="dialog_save">Guardar</string> <string name="dialog_save">Guardar</string>
<string name="settings_font_system">Igual que el sistema</string> <string name="settings_font_system">Igual que el sistema</string>
<string name="import_reminder_prompt_title">¿Aplicar tus recordatorios por defecto?</string>
<string name="import_reminder_prompt_body_none">Este evento fue importado sin ningún recordatorio.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Este evento fue importado con %1$d recordatorio.</item>
<item quantity="many">Este evento fue portado con %1$d recordatorios.</item>
<item quantity="other">Este evento fue importado con %1$d recordatorios.</item>
</plurals>
<string name="import_reminder_prompt_apply">Aplicar por defecto</string>
<string name="import_reminder_prompt_keep">Mantener como esta</string>
<string name="back">Atras</string>
<string name="settings_week_numbers">Números de semana</string>
<string name="settings_week_numbers_summary">Muestra los números de las semanas en la vista mensual</string>
<string name="calendars_export_title">Exportar calendarios</string>
<string name="calendars_export_hint">Escoge que calendarios incluir en el archivo .ics</string>
<string name="calendars_export_action">Exportar</string>
<string name="calendars_restore_header">Restaurar</string>
<string name="calendars_restore_action">Restaurar por archivo .ics</string>
<string name="calendars_restore_hint">Importar eventos desde un respaldo u otra aplicación de calendario.</string>
<string name="import_done_dedup_note">Se omitieron los eventos ya presentes en el calendario.</string>
<string name="import_done_added_label">Añadido</string>
<string name="import_done_skipped_label">Duplicados</string>
<string name="import_button">Importar</string>
<plurals name="import_title_count">
<item quantity="one">Importando %d evento</item>
<item quantity="many">Importando %d eventos</item>
<item quantity="other">Importando %d eventos</item>
</plurals>
</resources> </resources>

View File

@@ -6,4 +6,151 @@
<item>Days</item> <item>Days</item>
<item>Weeks</item> <item>Weeks</item>
</string-array> </string-array>
<string name="app_name">Calendrier</string>
<string name="app_tagline">Un calendrier moderne.</string>
<string name="state_loading">Chargement…</string>
<string name="state_retry">Réessayer</string>
<string name="state_failure_unknown">Une erreur s\'est produit.</string>
<string name="state_failure_permission">Un accès au Calendrier est nécessaire.</string>
<string name="state_failure_permission_action">Autoriser l\'accès</string>
<string name="state_failure_no_calendars">Aucun calendrier configuré.</string>
<string name="state_failure_no_calendars_action">Ouvrir les paramètres du calendrier.</string>
<string name="state_failure_provider">Ne peut pas lire le calendrier.</string>
<string name="permission_rationale_title">Tous les évènements ont été trouvés.</string>
<string name="permission_rationale_body">Calendula a besoin d\'avoir accès à votre calendrier pour afficher et accéder aux événements. C\'est tout ce dont nous avons besoin, aucune données ne sort de votre appareil.</string>
<string name="permission_request_button">Autoriser l\'accès au calendrier</string>
<string name="permission_denied_title">Accès au calendrier refusé.</string>
<string name="permission_denied_body">Calendula ne peut pas afficher les évènements si elle ne peut pas accéder au calendrier. Vous pouvez l\'autoriser à tout moment dans les paramètres.</string>
<string name="permission_open_settings_button">Ouvrir les paramètres.</string>
<string name="permission_retry_button">Essayer à nouveau</string>
<string name="permission_benefit_private_title">Reste sur votre appareil</string>
<string name="permission_benefit_private_body">Vos calendriers sont lus localement et ne quittent jamais le téléphone.</string>
<string name="permission_benefit_sync_title">Tous vos calendriers, réunis</string>
<string name="permission_benefit_privacy_title">Aucun suivi, jamais</string>
<string name="permission_benefit_privacy_body">Aucune collecte de données, aucune analyse, pas de pubs</string>
<string name="permission_privacy_footnote">Reste sur votre appareil - Pas d\'utilisation d\'Internet</string>
<string name="month_prev">Mois précédent</string>
<string name="month_next">Prochain mois</string>
<string name="month_today_action">Aujourd\'hui</string>
<string name="month_more_actions">Plus d\'actions</string>
<string name="month_open_menu">Ouvrir le menu</string>
<string name="month_action_settings">Paramètres</string>
<string name="month_a11y_today_prefix">Aujourd\'hui</string>
<string name="week_today_action">Cette semaine</string>
<string name="day_today_action">Aujourd\'hui</string>
<string name="event_detail_back">Retour</string>
<string name="event_detail_edit">Modifier</string>
<string name="event_detail_delete">Supprimer</string>
<string name="event_detail_share">Partager</string>
<string name="event_share_chooser_title">Evénement partagé</string>
<string name="event_share_failed">Impossible de partager cet événement.</string>
<string name="event_delete_title">Evénement supprimé?</string>
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
<string name="event_delete_option_occurrence">Seulement cet événement</string>
<string name="event_delete_option_following">Cet événement et tous ceux qui suivent</string>
<string name="event_delete_option_series">Tous les événements de la série</string>
<string name="event_edit_recurring_title">Modifier l\'événement récurrent</string>
<string name="event_delete_failed">Impossible de supprimer l\'événement</string>
<string name="event_delete_write_denied">Calendula a besoin des droits d\'écriture pour supprimer des événements</string>
<string name="dialog_cancel">Annuler</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Nouvel événement</string>
<string name="event_edit_close">Fermer</string>
<string name="event_edit_save">Sauvegarder</string>
<string name="event_edit_title_hint">Ajouter un titre</string>
<string name="event_edit_managed_hint">dirigé par \"%1$s\" - titre, date, et répétitions sont synchronisés avec les contacts. Pour rappel, vous pouvez modifier la localisation et les notes à tout instant.</string>
<string name="event_edit_starts">Début</string>
<string name="event_edit_ends">Fin</string>
<string name="event_edit_error_end_before_start">La fin commence avant le début</string>
<string name="event_edit_error_no_calendar">Aucun calendrier n\'est disponible en écriture</string>
<string name="event_edit_save_failed">Impossible d\'enregistrer l\'événement</string>
<string name="event_edit_write_denied">Calendula a besoin des droits d\'écriture pour créer un événement</string>
<string name="event_edit_more_fields">Plus de champs</string>
<string name="event_edit_add">Ajouter</string>
<string name="event_edit_add_reminder">Ajouter un rappel</string>
<string name="event_edit_remove_reminder">Supprimer le rappel</string>
<string name="event_edit_attendees">Invités</string>
<string name="event_edit_add_guest">Ajouter un invité</string>
<string name="event_edit_add_guest_hint">Ajouter un invité à partir d\'un email…</string>
<string name="event_edit_add_guest_from_contacts">Ajouter à partir des contacts</string>
<string name="event_edit_location_from_contacts">Sélectionner une adresse dans les contacts</string>
<string name="event_edit_remove_guest">Retirer un invité</string>
<string name="event_edit_attendee_required">Requis</string>
<string name="event_edit_attendee_optional">Optionnel</string>
<string name="event_edit_attendees_note_synced">Calendula n\'envoie pas d\'invitations. Votre calendrier par défaut peut envoyer des e-mails aux invités lors de la synchronisation.</string>
<string name="event_edit_attendees_note_local">Stocké sur cet appareil. Personne d\'autres n\'y a accès.</string>
<string name="event_edit_reminder_custom">Personnaliser</string>
<string name="reminder_unit_minutes">minutes</string>
<string name="reminder_unit_hours">heures</string>
<string name="reminder_unit_days">jours</string>
<string name="reminder_unit_weeks">semaines</string>
<string name="event_edit_availability">Disponibilité</string>
<string name="event_edit_visibility">Visibilité</string>
<string name="event_edit_color">Couleur</string>
<string name="event_edit_color_default">Couleur du calendrier</string>
<string name="event_edit_color_custom">Personnaliser la couleur</string>
<string name="event_edit_color_reset">Réinitialiser</string>
<string name="event_edit_color_unsupported">Non disponible pour ce calendrier</string>
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications?</string>
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
<string name="event_edit_conflict_discard_hint">Lévénement reste tel quil est maintenant</string>
<string name="event_edit_gone_title">Evénement supprimé</string>
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut?</string>
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
<item quantity="many">Cet événement a été importé avec %1$d rappels.</item>
<item quantity="other">Cet événement a été importé avec %1$d rappels.</item>
</plurals>
<string name="import_reminder_prompt_apply">Appliquer par défaut</string>
<string name="import_reminder_prompt_keep">Garder tel quel</string>
<string name="event_edit_recurrence_none">Pas de répétitions</string>
<string name="event_edit_recurrence_custom">Personnaliser</string>
<string name="event_edit_recurrence_every">Chaque</string>
<string name="recurrence_unit_days">jours</string>
<string name="recurrence_unit_weeks">semaines</string>
<string name="recurrence_unit_months">mois</string>
<string name="recurrence_unit_years">années</string>
<string name="event_edit_recurrence_ends">Fin</string>
<string name="event_edit_recurrence_end_never">Jamais</string>
<string name="event_edit_recurrence_end_until">A une date</string>
<string name="event_edit_recurrence_end_count">Après un nombre de fois</string>
<string name="event_edit_recurrence_times">fois</string>
<string name="event_edit_error_recurrence_ends_before_start">Les répétitions s\'arrêtent avant le début de l\'événement</string>
<string name="event_availability_busy">Occupé</string>
<string name="event_access_default">Par défaut</string>
<string name="event_access_public">Publique</string>
<string name="event_detail_all_day">Journée entière</string>
<string name="event_detail_calendar">Calendrier</string>
<string name="event_detail_calendar_unknown">Calendrier non reconnu</string>
<string name="event_detail_location">Lieux</string>
<string name="event_detail_description">Description</string>
<string name="event_detail_attendees">participants</string>
<string name="event_detail_recurrence">Récurrence</string>
<string name="event_detail_recurring">Evénement répété</string>
<string name="recurrence_daily">Tous les jours</string>
<string name="recurrence_weekly">Toutes les semaines</string>
<string name="recurrence_monthly">Tous les mois</string>
<string name="recurrence_yearly">Tous les ans</string>
<string name="recurrence_every_n_days">Tous les %1$d jours</string>
<string name="recurrence_every_n_weeks">Toutes les %1$d semaines</string>
<string name="recurrence_every_n_months">Tous les %1$d mois</string>
<string name="recurrence_every_n_years">Tous les %1$d ans</string>
<string name="recurrence_on_days">%1$s sur %2$s</string>
<string name="recurrence_with_until">%1$s jusqu\'à %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d fois</string>
<string name="event_detail_not_found">Cet événement n\'existe plus.</string>
<string name="event_attendee_accepted">Accepté</string>
<string name="event_attendee_declined">Refusé</string>
<string name="event_attendee_tentative">Provisoire</string>
<string name="event_attendee_needs_action">Aucune réponse</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Rappels</string>
</resources> </resources>

View File

@@ -6,4 +6,295 @@
<item>Days</item> <item>Days</item>
<item>Weeks</item> <item>Weeks</item>
</string-array> </string-array>
<string name="app_tagline">Nowoczesny kalendarz.</string>
<string name="state_loading">Ładowanie…</string>
<string name="state_retry">Spróbuj ponownie</string>
<string name="state_failure_unknown">Coś poszło nie tak.</string>
<string name="state_failure_permission">Dostęp do kalendarza jest wymagany.</string>
<string name="state_failure_permission_action">Przyznaj dostęp</string>
<string name="state_failure_no_calendars">Brak skonfigurowanych kalendarzy.</string>
<string name="state_failure_no_calendars_action">Otwórz systemowe ustawienia kalendarza</string>
<string name="state_failure_provider">Nie udało się kalendarza.</string>
<string name="permission_rationale_title">Zobacz wszystkie swoje wydarzenia w pięknej oprawie</string>
<string name="permission_rationale_body">Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. To jedyne, o co prosi na wstępie — i żadne dane nigdy nie opuszczają Twojego urządzenia.</string>
<string name="permission_request_button">Przyznaj dostęp do kalendarza</string>
<string name="permission_denied_title">Brak dostępu do kalendarza</string>
<string name="permission_denied_body">Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych.</string>
<string name="permission_open_settings_button">Otwórz ustawienia systemowe</string>
<string name="permission_retry_button">Spróbuj ponownie</string>
<string name="permission_benefit_private_title">Pozostaje na Twoim urządzeniu</string>
<string name="permission_benefit_private_body">Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszczają telefonu.</string>
<string name="permission_benefit_sync_title">Wszystkie Twoje kalendarze w jednym miejscu</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia.</string>
<string name="permission_benefit_privacy_title">Nigdy żadnego śledzenia</string>
<string name="permission_benefit_privacy_body">Zero telemetrii, zero analityki, bez reklam.</string>
<string name="permission_privacy_footnote">Pozostaje na Twoim urządzeniu - brak uprawnień do internetu</string>
<string name="month_prev">Poprzedni miesiąc</string>
<string name="month_next">Następny miesiąc</string>
<string name="month_today_action">Dzisiaj</string>
<string name="month_more_actions">Więcej akcji</string>
<string name="month_open_menu">Otwórz menu</string>
<string name="month_action_settings">Ustawienia</string>
<string name="month_a11y_today_prefix">Dzisiaj</string>
<string name="week_today_action">Bieżący tydzień</string>
<string name="week_number_label">tydz.</string>
<string name="day_today_action">Dzisiaj</string>
<string name="event_detail_back">Wstecz</string>
<string name="event_detail_edit">Edytuj</string>
<string name="event_detail_delete">Usuń</string>
<string name="event_detail_share">Udostępnij</string>
<string name="event_share_chooser_title">Udostępnij wydarzenie</string>
<string name="event_share_failed">Nie udało się udostępnić tego wydarzenia.</string>
<string name="event_delete_title">Usunąć wydarzenie?</string>
<string name="event_delete_body">Usunięto wydarzenie z Twojego kalendarza i wszystkich zsynchronizowanych urządzeń.</string>
<string name="event_delete_recurring_title">Usuń powtarzające się wydarzenie</string>
<string name="event_delete_option_occurrence">Tylko to wystąpienie</string>
<string name="event_delete_option_following">To i wszystkie następne wystąpienia</string>
<string name="event_delete_option_series">Wszystkie wystąpienia w serii</string>
<string name="event_edit_recurring_title">Edytuj powtarzające się wydarzenie</string>
<string name="event_delete_failed">Nie udało się usunąć tego wydarzenia</string>
<string name="event_delete_write_denied">Calendula wymaga uprawnień do zapisu, aby móc usuwać wydarzenia</string>
<string name="dialog_cancel">Anuluj</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Nowe wydarzenie</string>
<string name="event_edit_close">Zamknij</string>
<string name="event_edit_save">Zapisz</string>
<string name="event_edit_title_hint">Dodaj tytuł</string>
<string name="event_edit_managed_hint">Zarządzane przez „%1$s” — tytuł, data i powtarzanie są synchronizowane z Twoimi kontaktami. Możesz edytować przypomnienia, lokalizację oraz notatki.</string>
<string name="event_edit_starts">Początek</string>
<string name="event_edit_ends">Koniec</string>
<string name="event_edit_error_end_before_start">Koniec wcześniejszy niż początek</string>
<string name="event_edit_error_no_calendar">Brak dostępnych kalendarzy z uprawnieniami do zapisu</string>
<string name="event_edit_save_failed">Nie udało się zapisać wydarzenia</string>
<string name="event_edit_write_denied">Calendula potrzebuje uprawnień do zapisu by tworzyć wydarzenia</string>
<string name="event_edit_more_fields">Więcej pól</string>
<string name="event_edit_add">Dodaj</string>
<string name="event_edit_add_reminder">Dodaj przypomnienie</string>
<string name="event_edit_remove_reminder">Usuń przypomnienie</string>
<string name="event_edit_attendees">Goście</string>
<string name="event_edit_add_guest">Dodaj gościa</string>
<string name="event_edit_add_guest_hint">Dodaj gościa poprzez adres e-mail…</string>
<string name="event_edit_add_guest_from_contacts">Dodaj z kontaktów</string>
<string name="event_edit_location_from_contacts">Wybierz adres z kontaktów</string>
<string name="event_edit_remove_guest">Usuń gościa</string>
<string name="event_edit_attendee_required">Wymagany</string>
<string name="event_edit_attendee_optional">Opcjonalny</string>
<string name="event_edit_attendees_note_synced">Calendula nie wysyła zaproszeń. Twoje konto kalendarza może wysyłać e-maile do gości podczas synchronizacji.</string>
<string name="event_edit_attendees_note_local">Przechowywane na urządzeniu. Nikt nie jest powiadamiany.</string>
<string name="event_edit_reminder_custom">Niestandardowy</string>
<string name="reminder_unit_minutes">minuty</string>
<string name="reminder_unit_hours">godziny</string>
<string name="reminder_unit_days">dni</string>
<string name="reminder_unit_weeks">tygodnie</string>
<string name="event_edit_availability">Dostępność</string>
<string name="event_edit_visibility">Widoczność</string>
<string name="event_edit_color">Kolor</string>
<string name="event_edit_color_default">Kolor kalendarza</string>
<string name="event_edit_color_custom">Niestandardowy kolor</string>
<string name="event_edit_color_reset">Resetuj</string>
<string name="event_edit_color_unsupported">Niedostępne dla tego kalendarza</string>
<string name="event_edit_color_unsupported_hint">Ten kalendarz nie udostępnia zestawu kolorów. Możesz zezwolić na niestandardowe kolory dla takich kalendarzy w Ustawieniach.</string>
<string name="event_edit_color_sync_warning">Ten kalendarz może usunąć lub nadpisać kolor przy następnej synchronizacji.</string>
<string name="event_edit_conflict_title">Wydarzenie zostało zmienione w innym miejscu</string>
<string name="event_edit_conflict_body">Podczas edycji to wydarzenie zostało zmienione przez synchronizację lub inną aplikację. Co ma się stać z Twoimi zmianami?</string>
<string name="event_edit_conflict_overwrite">Zapisz moje zmiany</string>
<string name="event_edit_conflict_overwrite_hint">Tylko edytowane przez Ciebie pola nadpiszą zmiany zewnętrzne</string>
<string name="event_edit_conflict_discard">Odrzuć moje zmiany</string>
<string name="event_edit_conflict_discard_hint">Wydarzenie pozostaje bez zmian</string>
<string name="event_edit_gone_title">Wydarzenie usunięte</string>
<string name="event_edit_gone_body">To wydarzenie zostało w międzyczasie usunięte, na przykład na innym urządzeniu. Twoje zmiany nie mogą już zostać zapisane.</string>
<string name="import_reminder_prompt_title">Użyć domyślnego przypomnienia?</string>
<string name="import_reminder_prompt_body_none">To wydarzenie zostało zaimportowane bez żadnych przypomnień.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">To wydarzenie zostało zaimportowane z %1$d przypomnieniem.</item>
<item quantity="few">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
<item quantity="many">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
<item quantity="other">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
</plurals>
<string name="import_reminder_prompt_apply">Użyj domyślnego</string>
<string name="import_reminder_prompt_keep">Pozostaw bez zmian</string>
<string name="event_edit_recurrence_none">Nie powtarza się</string>
<string name="event_edit_recurrence_custom">Własne</string>
<string name="event_edit_recurrence_every">Co</string>
<string name="recurrence_unit_days">dni</string>
<string name="recurrence_unit_weeks">tygodnie</string>
<string name="recurrence_unit_months">miesiące</string>
<string name="recurrence_unit_years">lata</string>
<string name="event_edit_recurrence_ends">Koniec</string>
<string name="event_edit_recurrence_end_never">Nigdy</string>
<string name="event_edit_recurrence_end_until">Wybrana data</string>
<string name="event_edit_recurrence_end_count">Po wybranej liczbie powtórzeń</string>
<string name="event_edit_recurrence_times">razy</string>
<string name="event_edit_error_recurrence_ends_before_start">Koniec powtarzania przed początkiem wydarzenia</string>
<string name="event_availability_busy">Zajęty</string>
<string name="event_access_default">Domyślny</string>
<string name="event_access_public">Publiczny</string>
<string name="event_detail_all_day">Cały dzień</string>
<string name="event_detail_calendar">Kalendarz</string>
<string name="event_detail_calendar_unknown">Nieznany kalendarz</string>
<string name="event_detail_location">Lokalizacja</string>
<string name="event_detail_description">Opis</string>
<string name="event_detail_attendees">Uczestnicy</string>
<string name="event_detail_recurrence">Powtarzanie</string>
<string name="event_detail_recurring">Powtarzające się wydarzenie</string>
<string name="recurrence_daily">Codziennie</string>
<string name="recurrence_weekly">Co tydzień</string>
<string name="recurrence_monthly">Co miesiąc</string>
<string name="recurrence_yearly">Co rok</string>
<string name="recurrence_every_n_days">Co %1$d dni</string>
<string name="recurrence_every_n_weeks">Co %1$d tygodni</string>
<string name="recurrence_every_n_months">Co %1$d miesięcy</string>
<string name="recurrence_every_n_years">Co %1$d lata</string>
<string name="recurrence_on_days">%1$s w %2$s</string>
<string name="recurrence_with_until">%1$s do %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d razy</string>
<string name="event_detail_not_found">To wydarzenie już nie istnieje.</string>
<string name="event_attendee_accepted">Zaakceptowano</string>
<string name="event_attendee_declined">Odrzucono</string>
<string name="event_attendee_tentative">Niepewny</string>
<string name="event_attendee_needs_action">Brak odpowiedzi</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Przypomnienia</string>
<string name="event_detail_timezone">Strefa czasowa</string>
<string name="event_status_tentative">Niepewne</string>
<string name="event_status_cancelled">Odwołane</string>
<string name="event_availability_free">Dostępny</string>
<string name="event_access_private">Prywatne</string>
<string name="event_access_confidential">Poufne</string>
<string name="event_attendee_organizer">Organizator</string>
<string name="event_attendee_optional">Opcjonalny</string>
<string name="event_attendee_resource">Zasób</string>
<string name="event_detail_self_response">Twoja odpowiedź: %1$s</string>
<string name="reminder_at_time">W momencie wydarzenia</string>
<string name="reminder_default">Domyślne przypomnienie</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minuta przed</item>
<item quantity="few">%d minuty przed</item>
<item quantity="many">%d minut przed</item>
<item quantity="other">%d minut przed</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d godzina przed</item>
<item quantity="few">%d godziny przed</item>
<item quantity="many">%d godzin przed</item>
<item quantity="other">%d godzin przed</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d dzień przed</item>
<item quantity="few">%d dni przed</item>
<item quantity="many">%d dni przed</item>
<item quantity="other">%d dni przed</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d tydzień przed</item>
<item quantity="few">%d tygodnie przed</item>
<item quantity="many">%d tygodni przed</item>
<item quantity="other">%d tygodni przed</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d minuta</item>
<item quantity="few">%d minuty</item>
<item quantity="many">%d minut</item>
<item quantity="other">%d minut</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d godzina</item>
<item quantity="few">%d godziny</item>
<item quantity="many">%d godzin</item>
<item quantity="other">%d godzin</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d dzień</item>
<item quantity="few">%d dni</item>
<item quantity="many">%d dni</item>
<item quantity="other">%d dni</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">%d tydzień</item>
<item quantity="few">%d tygodnie</item>
<item quantity="many">%d tygodni</item>
<item quantity="other">%d tygodni</item>
</plurals>
<string name="event_untitled">(Bez tytułu)</string>
<string name="reminder_channel_name">Przypomnienia o wydarzeniach</string>
<string name="reminder_channel_description">Powiadomienia w czasie przypomnień o Twoich wydarzeniach</string>
<string name="reminder_onboarding_title">Nigdy nie przegap wydarzenia</string>
<string name="reminder_onboarding_body">Android sam nie wyświetla przypomnień o wydarzeniach — musi to robić aplikacja kalendarza. Pozwól na to Calenduli.</string>
<string name="reminder_benefit_delivery_title">Niezawodne przypomnienia</string>
<string name="reminder_benefit_delivery_body">Każde przypomnienie o Twoich wydarzeniach przychodzi jako powiadomienie, dokładnie na czas.</string>
<string name="reminder_benefit_duplicates_title">Używasz innej aplikacji kalendarza?</string>
<string name="reminder_benefit_duplicates_body">Jeśli inna aplikacja również wysyła przypomnienia, będą one widoczne podwójnie - wyłącz je tam lub tutaj.</string>
<string name="reminder_benefit_reversible_title">Możesz to zmienić w dowolnej chwili</string>
<string name="reminder_benefit_reversible_body">Przełącznik znajduje się w Ustawieniach, w sekcji Powiadomienia.</string>
<string name="reminder_onboarding_enable_button">Włącz przypomnienia</string>
<string name="reminder_onboarding_skip_button">Nie teraz</string>
<string name="reminder_action_snooze">Drzemka</string>
<string name="reminder_action_dismiss">Odrzuć</string>
<string name="view_month">Miesiąc</string>
<string name="view_week">Tydzień</string>
<string name="view_day">Dzień</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Widok</string>
<string name="drawer_jump_to_date">Skocz do daty</string>
<string name="agenda_today_action">Dzisiaj</string>
<string name="agenda_header_today">Dzisiaj</string>
<string name="agenda_header_tomorrow">Jutro</string>
<string name="agenda_empty_title">Jesteś na bieżąco</string>
<string name="search_action">Szukaj</string>
<string name="search_hint">Szukaj wydarzeń</string>
<string name="search_back">Wstecz</string>
<string name="search_clear">Wyczyść</string>
<string name="search_idle_hint">Przeszukuj swoje wydarzenia według tytułu, lokalizacji lub notatek.</string>
<string name="search_empty">Brak wydarzeń pasujących do „%1$s”.</string>
<string name="widget_agenda_title">Nadchodzące wydarzenia</string>
<string name="widget_agenda_label">Agenda Calendula</string>
<string name="widget_month_label">Miesiąc Calendula</string>
<string name="widget_refresh">Odśwież</string>
<string name="widget_new_event">Nowe wydarzenie</string>
<string name="widget_needs_permission">Otwórz Calendula aby przyznać dostęp do kalendarza</string>
<string name="widget_prev_month">Poprzedni miesiąc</string>
<string name="widget_next_month">Następny miesiąc</string>
<string name="widget_today">Dzisiaj</string>
<string name="filter_title">Kalendarze</string>
<string name="settings_title">Ustawienia</string>
<string name="settings_back">Wstecz</string>
<string name="back">Wstecz</string>
<string name="settings_section_appearance">Wygląd</string>
<string name="settings_theme">Motyw</string>
<string name="settings_theme_system">Systemowy</string>
<string name="settings_theme_light">Jasny</string>
<string name="settings_theme_dark">Ciemny</string>
<string name="settings_default_view">Domyślny widok</string>
<string name="settings_dynamic_color">Dynamiczne kolory</string>
<string name="settings_dynamic_color_unavailable">Wymaga Androida 12 lub nowszego</string>
<string name="settings_font_headings">Czcionka nagłówków</string>
<string name="settings_font_body">Czcionka tekstu</string>
<string name="settings_font_system">Domyślna systemu</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Wybierz plik…</string>
<string name="settings_font_custom_selected">Niestandardowa czcionka</string>
<string name="app_name">Calendula</string>
<string name="settings_font_import_failed">Nie udało się odczytać czcionki z pliku</string>
<string name="settings_week_start">Pierwszy dzień tygodnia</string>
<string name="settings_week_start_auto">Automatycznie</string>
<string name="settings_week_numbers">Numery tygodni</string>
<string name="settings_week_numbers_summary">Pokaż numery tygodni w widoku miesiąca</string>
<string name="settings_time_format">Format czasu</string>
<string name="settings_time_format_auto">Automatyczny</string>
<string name="settings_time_format_12h">12-godzinny (2:00 PM)</string>
<string name="settings_time_format_24h">24-godzinny (14:00)</string>
<string name="settings_hour_lines">Linie godzin</string>
<string name="settings_hour_lines_summary">Pokaż linię oddzielającą przy każdej godzinie w widoku tygodnia i dnia</string>
<string name="settings_dim_completed">Przyciemnij zakończone wydarzenia</string>
<string name="settings_dim_completed_summary">Przyciemnij wydarzenia, które już się zakończyły w widoku miesiąca i tygodnia</string>
<string name="settings_past_events">Przeszłe wydarzenia</string>
<string name="settings_past_events_show">Pokaż</string>
<string name="settings_past_events_dim">Przyciemnij</string>
<string name="settings_past_events_hide">Ukryj</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Zakres agendy</string>
<string name="settings_agenda_range_hint">Jak daleko w przód wyświetlane są wydarzenia na ekranie Agendy.</string>
<string name="settings_agenda_widget_range">Zakres widżetu agendy</string>
</resources> </resources>

View File

@@ -1,6 +1,10 @@
<resources> <resources>
<string name="app_name">Calendula</string> <string name="app_name">Calendula</string>
<string name="app_tagline">A modern calendar.</string> <string name="app_tagline">A modern calendar.</string>
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
translatable="false": this string becomes a launcher component's label, so
it must never be changed by an unreviewed community translation. -->
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
<!-- Loading / Failure / Success generic strings (used across screens) --> <!-- Loading / Failure / Success generic strings (used across screens) -->
<string name="state_loading">Loading…</string> <string name="state_loading">Loading…</string>
@@ -49,6 +53,7 @@
<string name="event_detail_edit">Edit</string> <string name="event_detail_edit">Edit</string>
<string name="event_detail_delete">Delete</string> <string name="event_detail_delete">Delete</string>
<string name="event_detail_share">Share</string> <string name="event_detail_share">Share</string>
<string name="event_detail_duplicate">Duplicate</string>
<string name="event_share_chooser_title">Share event</string> <string name="event_share_chooser_title">Share event</string>
<string name="event_share_failed">Couldn\'t share this event.</string> <string name="event_share_failed">Couldn\'t share this event.</string>
<string name="event_delete_title">Delete event?</string> <string name="event_delete_title">Delete event?</string>
@@ -98,6 +103,18 @@
<string name="event_edit_availability">Availability</string> <string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string> <string name="event_edit_visibility">Visibility</string>
<!-- Event form — time zone -->
<string name="event_edit_timezone_device">Device time zone</string>
<string name="event_edit_timezone_device_summary">Follows wherever you are</string>
<string name="event_edit_timezone_search">Search time zones</string>
<string name="event_edit_timezone_recent">Recent</string>
<string name="event_edit_timezone_all">All time zones</string>
<string name="event_edit_timezone_none">No time zone matches “%1$s”</string>
<!-- Shown under the time fields when the event is pinned to another zone:
the same event expressed where the user actually is. %1$s is a time
range, e.g. "2:00 PM 3:00 PM". -->
<string name="event_edit_timezone_local_time">%1$s your time</string>
<!-- Event form — per-event color --> <!-- Event form — per-event color -->
<string name="event_edit_color">Color</string> <string name="event_edit_color">Color</string>
<string name="event_edit_color_default">Calendar color</string> <string name="event_edit_color_default">Calendar color</string>
@@ -135,6 +152,9 @@
<string name="recurrence_unit_weeks">weeks</string> <string name="recurrence_unit_weeks">weeks</string>
<string name="recurrence_unit_months">months</string> <string name="recurrence_unit_months">months</string>
<string name="recurrence_unit_years">years</string> <string name="recurrence_unit_years">years</string>
<!-- Shown in place of the live rule read-out when an amount field holds a
value outside 1999 (i.e. 0), so the greyed-out OK button has a reason. -->
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
<string name="event_edit_recurrence_ends">Ends</string> <string name="event_edit_recurrence_ends">Ends</string>
<string name="event_edit_recurrence_end_never">Never</string> <string name="event_edit_recurrence_end_never">Never</string>
<string name="event_edit_recurrence_end_until">On a date</string> <string name="event_edit_recurrence_end_until">On a date</string>
@@ -236,6 +256,9 @@
<string name="reminder_onboarding_skip_button">Not now</string> <string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_action_snooze">Snooze</string> <string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Dismiss</string> <string name="reminder_action_dismiss">Dismiss</string>
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
<string name="reminder_day_tomorrow">Tomorrow</string>
<string name="reminder_day_yesterday">Yesterday</string>
<!-- View switcher (M1) --> <!-- View switcher (M1) -->
<string name="view_month">Month</string> <string name="view_month">Month</string>
@@ -252,6 +275,13 @@
<string name="agenda_header_today">Today</string> <string name="agenda_header_today">Today</string>
<string name="agenda_header_tomorrow">Tomorrow</string> <string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">You\'re all caught up</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 --> <!-- Event search -->
<string name="search_action">Search</string> <string name="search_action">Search</string>
@@ -288,6 +318,8 @@
<string name="settings_default_view">Default view</string> <string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string> <string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string> <string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_soften_colors">Soften calendar colours</string>
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
<string name="settings_font_headings">Headings font</string> <string name="settings_font_headings">Headings font</string>
<string name="settings_font_body">Body font</string> <string name="settings_font_body">Body font</string>
<string name="settings_font_system">System default</string> <string name="settings_font_system">System default</string>
@@ -301,6 +333,10 @@
<string name="settings_week_start_auto">Automatic</string> <string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_numbers">Week numbers</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_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_app_name">App name</string>
<string name="settings_app_name_summary">Show Calendula as “Calendar” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching.</string>
<string name="settings_time_format">Time format</string> <string name="settings_time_format">Time format</string>
<string name="settings_time_format_auto">Automatic</string> <string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</string> <string name="settings_time_format_12h">12-hour (2:00 PM)</string>
@@ -318,6 +354,8 @@
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string> <string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string> <string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string> <string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_agenda_show_today">Always show today</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
<string name="settings_agenda_range_bar">Range bar</string> <string name="settings_agenda_range_bar">Range bar</string>
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string> <string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
<string name="agenda_range_day">Today</string> <string name="agenda_range_day">Today</string>

View File

@@ -11,6 +11,8 @@
<locale android:name="en" /> <locale android:name="en" />
<locale android:name="de" /> <locale android:name="de" />
<locale android:name="es" /> <locale android:name="es" />
<locale android:name="fr" />
<locale android:name="it" /> <locale android:name="it" />
<locale android:name="pl" />
<locale android:name="zh-CN" /> <locale android:name="zh-CN" />
</locale-config> </locale-config>

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.calendula.data.appname
import android.content.pm.PackageManager
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* Pure decision logic behind the app-name toggle (issue #44). The framework seam
* ([LauncherNameManager.set]/[current], which call `PackageManager`) is covered
* on-device; these tests pin the read interpretation and the write ordering.
*/
class LauncherNameManagerTest {
@Test
fun `enabled calendar alias reads as CALENDAR`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED))
.isEqualTo(LauncherName.CALENDAR)
}
@Test
fun `default and disabled calendar alias read as CALENDULA`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT))
.isEqualTo(LauncherName.CALENDULA)
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED))
.isEqualTo(LauncherName.CALENDULA)
}
@Test
fun `write plan enables the target alias before disabling the other`() {
val toCalendar = aliasWritePlan(LauncherName.CALENDAR)
assertThat(toCalendar).containsExactly(
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
).inOrder()
val toCalendula = aliasWritePlan(LauncherName.CALENDULA)
assertThat(toCalendula).containsExactly(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
).inOrder()
}
@Test
fun `write plan never disables both aliases in the same step`() {
// The first step is always an enable, so the launcher can never observe a
// zero-entry transient regardless of switch direction.
for (target in LauncherName.entries) {
assertThat(aliasWritePlan(target).first().enabled).isTrue()
}
}
}

View File

@@ -312,6 +312,46 @@ class CalendarRepositoryImplTest {
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated)) assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
} }
@Test
fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
)
val updated = original.copy(calendarId = 3L)
val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated)
assertThat(newId).isEqualTo(77L)
assertThat(fake.movedEvents).containsExactly(
FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated),
)
}
@Test
fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("insert moved event into calendar id=3")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
try {
repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L))
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("3")
}
}
@Test @Test
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest { fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {

View File

@@ -19,7 +19,14 @@ class EventWriteMapperTest {
isAllDay: Boolean = false, isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)), end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end) timezone: String? = null,
): EventForm = EventForm(
calendarId = 1L,
isAllDay = isAllDay,
start = start,
end = end,
timezone = timezone,
)
@Test @Test
fun `timed event resolves wall clock in the given zone`() { fun `timed event resolves wall clock in the given zone`() {
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
assertThat(times.timezone).isEqualTo("Europe/Berlin") assertThat(times.timezone).isEqualTo("Europe/Berlin")
} }
@Test
fun `pinned zone wins over the device zone`() {
val times = form(timezone = "America/New_York").toWriteTimes(berlin)
// 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give.
assertThat(times.timezone).isEqualTo("America/New_York")
assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L)
}
@Test
fun `an unparseable pinned zone falls back to the device zone`() {
val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin)
assertThat(times.timezone).isEqualTo("Europe/Berlin")
}
@Test
fun `all-day ignores a pinned zone and stays UTC`() {
val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin)
assertThat(times.timezone).isEqualTo("UTC")
}
@Test @Test
fun `all-day event lives at UTC midnights with exclusive end`() { fun `all-day event lives at UTC midnights with exclusive end`() {
val times = form(isAllDay = true).toWriteTimes(berlin) val times = form(isAllDay = true).toWriteTimes(berlin)
@@ -96,8 +123,18 @@ class EventWriteMapperTest {
private val seriesStart = 1_700_000_000_000L private val seriesStart = 1_700_000_000_000L
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> = private fun update(
buildEventUpdateValues(original, updated, seriesStart, berlin) original: EventForm,
updated: EventForm,
series: Long = seriesStart,
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
/** The instant [local] names in [zoneId], as the provider would store it. */
private fun instantAt(local: String, zoneId: String): Long =
java.time.LocalDateTime.parse(local)
.atZone(java.time.ZoneId.of(zoneId))
.toInstant()
.toEpochMilli()
@Test @Test
fun `pristine form produces no values`() { fun `pristine form produces no values`() {
@@ -105,6 +142,42 @@ class EventWriteMapperTest {
assertThat(update(original, original.copy())).isEmpty() assertThat(update(original, original.copy())).isEmpty()
} }
@Test
fun `editing the time of a pinned event keeps its zone`() {
// The regression this guards: the update used to stamp the device zone
// over the event's own, silently un-anchoring a foreign-zone event so it
// stopped tracking that zone across DST.
val original = form(timezone = "America/New_York")
val values = update(original, original.copy(title = "Standup", start = original.start))
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
)
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
.isEqualTo("America/New_York")
}
@Test
fun `changing only the zone still moves the event`() {
// Same wall-clock, different zone: the instant moves, so DTSTART must be
// rewritten even though start/end compare equal.
val original = form()
val values = update(original, original.copy(timezone = "America/New_York"))
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
// 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L)
}
@Test
fun `unpinning back to the device zone rewrites the times`() {
val original = form(timezone = "America/New_York")
val values = update(original, original.copy(timezone = null))
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
}
@Test @Test
fun `text-only edit writes just the changed columns`() { fun `text-only edit writes just the changed columns`() {
val original = form() val original = form()
@@ -156,6 +229,77 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
} }
@Test
fun `pinning a recurring event to another zone keeps the series wall clock`() {
// The regression this guards: the series DTSTART used to move by a
// millisecond delta measured at the *edited occurrence*. Here the series
// anchor sits in January (Berlin CET, +1) and the edited occurrence in
// July (Berlin CEST, +2), so the July delta is an hour off for January —
// the whole series would have drifted to 10:00 Tokyo.
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val values = update(original, original.copy(timezone = "Asia/Tokyo"), series)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Asia/Tokyo")
// The anchor still reads 09:00 — now 09:00 in Tokyo, not 10:00.
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(instantAt("2026-01-07T09:00", "Asia/Tokyo"))
}
@Test
fun `a time edit moves the series anchor in wall clock across a DST boundary`() {
// Anchor in winter, edited occurrence in summer: pushing the occurrence
// one hour later must leave the anchor at 10:00 winter time, not at an
// instant that re-reads as 11:00 once the offset differs.
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
.isEqualTo(instantAt("2026-01-07T10:00", "Europe/Berlin"))
}
@Test
fun `moving a recurring occurrence to another day shifts the anchor by whole days`() {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
}
@Test
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val values = update(original, original.copy(isAllDay = true), series)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
val dtStart = values[CalendarContract.Events.DTSTART] as Long
assertThat(dtStart % (24L * 60 * 60 * 1000)).isEqualTo(0L)
}
@Test @Test
fun `adding a recurrence keeps the times and writes rule plus duration`() { fun `adding a recurrence keeps the times and writes rule plus duration`() {
val original = form() val original = form()
@@ -409,4 +553,114 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null) assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null) assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
} }
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
private fun masterSnapshot(
isAllDay: Boolean = false,
dtStartMillis: Long = 1_781_164_800_000L,
dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L,
duration: String? = null,
rrule: String? = null,
rdate: String? = null,
exdate: String? = null,
) = MasterEventSnapshot(
title = "Standup",
isAllDay = isAllDay,
dtStartMillis = dtStartMillis,
dtEndMillis = dtEndMillis,
duration = duration,
rrule = rrule,
rdate = rdate,
exdate = exdate,
timezone = "Europe/Berlin",
availability = CalendarContract.Events.AVAILABILITY_BUSY,
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
status = CalendarContract.Events.STATUS_CONFIRMED,
location = "Room 1",
description = "",
)
@Test
fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() {
val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula")
assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L)
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
// Empty description clears explicitly; a raw/keyed colour is never copied
// (may be invalid on the target account — the copy inherits its colour).
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
}
@Test
fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() {
val values = buildMovedMasterValues(
masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"),
targetCalendarId = 9L,
uid = "u@calendula",
)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z")
// Recurring rows never carry DTEND (the provider's invariant).
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
}
@Test
fun `moved series without a stored duration derives it from DTEND`() {
val values = buildMovedMasterValues(
masterSnapshot(duration = null, rrule = "FREQ=DAILY"),
targetCalendarId = 9L,
uid = "u@calendula",
)
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
}
// --- buildCopiedExceptionValues (calendar move: exception replay) ---
private fun exceptionSnapshot(
isCancelled: Boolean = false,
duration: String? = null,
// Occurrence starts one hour into the day and runs 30 minutes.
dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L,
) = ExceptionRowSnapshot(
exceptionEventId = 42L,
originalInstanceMillis = 1_781_164_800_000L,
isCancelled = isCancelled,
status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED,
title = "Moved occurrence",
isAllDay = false,
dtStartMillis = 1_781_164_800_000L + 3_600_000L,
dtEndMillis = dtEndMillis,
duration = duration,
timezone = "Europe/Berlin",
availability = CalendarContract.Events.AVAILABILITY_BUSY,
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
location = "",
description = "Notes",
)
@Test
fun `copied modified occurrence carries the original instance and DURATION not DTEND`() {
val values = buildCopiedExceptionValues(exceptionSnapshot())
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
.isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L)
// 30-minute occurrence derived from DTEND, written as DURATION only.
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S")
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
}
@Test
fun `copied modified occurrence keeps a stored duration verbatim`() {
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
}
} }

View File

@@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource {
val insertedForms = mutableListOf<EventForm>() val insertedForms = mutableListOf<EventForm>()
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>() val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
data class MovedEvent(
val eventId: Long,
val targetCalendarId: Long,
val original: EventForm,
val updated: EventForm,
)
val movedEvents = mutableListOf<MovedEvent>()
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>() val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>() val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val deletedEventIds = mutableListOf<Long>() val deletedEventIds = mutableListOf<Long>()
@@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
allDayReminderTimes += allDayReminderTimeMinutes allDayReminderTimes += allDayReminderTimeMinutes
} }
override fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
writeError?.let { throw it }
movedEvents += MovedEvent(eventId, targetCalendarId, original, updated)
allDayReminderTimes += allDayReminderTimeMinutes
return nextInsertId
}
override fun updateOccurrence( override fun updateOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -18,41 +19,110 @@ class ReminderTimeTextTest {
private fun utcMidnight(date: LocalDate): Long = private fun utcMidnight(date: LocalDate): Long =
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
/** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */
private fun text(
beginMillis: Long,
endMillis: Long,
isAllDay: Boolean = false,
zone: ZoneId = berlin,
locale: Locale = Locale.GERMANY,
is24Hour: Boolean = true,
today: LocalDate? = null,
firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
): String = reminderTimeText(
beginMillis = beginMillis,
endMillis = endMillis,
isAllDay = isAllDay,
zone = zone,
locale = locale,
is24Hour = is24Hour,
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
tomorrowLabel = "Tomorrow",
yesterdayLabel = "Yesterday",
)
@Test @Test
fun `timed event on one day shows just the time range`() { fun `timed event today shows just the time range`() {
val text = reminderTimeText( val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin), beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("09:30 10:00") assertThat(text).isEqualTo("09:30 10:00")
} }
@Test @Test
fun `12-hour preference renders an am-pm time range`() { fun `12-hour preference renders an am-pm time range`() {
val text = reminderTimeText( val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin), beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.US, locale = Locale.US,
is24Hour = false, is24Hour = false,
) )
assertThat(text).isEqualTo("2:00 PM 3:00 PM") assertThat(text).isEqualTo("2:00 PM 3:00 PM")
} }
@Test
fun `tomorrow's event is prefixed with the tomorrow label`() {
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Tomorrow, 09:30 10:00")
}
@Test
fun `yesterday's event is prefixed with the yesterday label`() {
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Yesterday, 09:30 10:00")
}
@Test
fun `an event later this week is prefixed with the short weekday`() {
// 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("So., 09:30 10:00")
}
@Test
fun `the week boundary honours the week-start setting`() {
// Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the
// Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week
// and must render as a date — the opposite of the Monday-start case above.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
firstDayOfWeek = DayOfWeek.SUNDAY,
)
assertThat(text).isEqualTo("14.06.2026, 09:30 10:00")
}
@Test
fun `an event next week falls back to the exact date, not a weekday`() {
// 2026-06-15 is the following Monday — a weekday alone would be ambiguous.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("15.06.2026, 09:30 10:00")
}
@Test @Test
fun `timed event crossing midnight includes both dates`() { fun `timed event crossing midnight includes both dates`() {
val text = reminderTimeText( val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin), beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).contains("11.06.2026") assertThat(text).contains("11.06.2026")
assertThat(text).contains("12.06.2026") assertThat(text).contains("12.06.2026")
@@ -61,28 +131,35 @@ class ReminderTimeTextTest {
@Test @Test
fun `all-day single day shows one date, read in UTC`() { fun `all-day single day shows one date, read in UTC`() {
val text = reminderTimeText( val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)), endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true, isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is // Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day. // 02:00 in Berlin — naive local reading would shift the day.
zone = berlin, today = LocalDate.of(2026, 6, 11),
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026") assertThat(text).isEqualTo("11.06.2026")
} }
@Test
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("12.06.2026")
}
@Test @Test
fun `all-day multi-day shows the last covered day, not the exclusive end`() { fun `all-day multi-day shows the last covered day, not the exclusive end`() {
val text = reminderTimeText( val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true, isAllDay = true,
zone = berlin, today = LocalDate.of(2026, 6, 11),
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026 12.06.2026") assertThat(text).isEqualTo("11.06.2026 12.06.2026")
} }
@@ -90,13 +167,11 @@ class ReminderTimeTextTest {
@Test @Test
fun `degenerate all-day range never renders an inverted span`() { fun `degenerate all-day range never renders an inverted span`() {
val day = utcMidnight(LocalDate.of(2026, 6, 11)) val day = utcMidnight(LocalDate.of(2026, 6, 11))
val text = reminderTimeText( val text = text(
beginMillis = day, beginMillis = day,
endMillis = day, endMillis = day,
isAllDay = true, isAllDay = true,
zone = berlin, today = LocalDate.of(2026, 6, 11),
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026") assertThat(text).isEqualTo("11.06.2026")
} }

View File

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

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,39 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
/** Covers the agenda widget's "always show today" anchor logic (#35). */
class AnchorTodayTest {
private val today = LocalDate(2026, 6, 17)
private val tomorrow = LocalDate(2026, 6, 18)
@Test
fun `disabled leaves the days untouched even when today is absent`() {
val days = listOf(AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days)
}
@Test
fun `enabled prepends an empty today when it is missing`() {
val days = listOf(AgendaDay(tomorrow, emptyList()))
val result = anchorTodayIfMissing(days, today, enabled = true)
assertThat(result).hasSize(2)
assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList()))
assertThat(result[1]).isEqualTo(days.first())
}
@Test
fun `enabled anchors today into an otherwise empty list`() {
val result = anchorTodayIfMissing(emptyList(), today, enabled = true)
assertThat(result).containsExactly(AgendaDay(today, emptyList()))
}
@Test
fun `enabled is a no-op when today already has its own day`() {
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
}
}

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

@@ -0,0 +1,69 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import java.time.LocalDate
import java.time.ZoneId
import org.junit.jupiter.api.Test
/**
* The read side of the UNTIL round-trip. `SimpleRecurrence.toRRule` deliberately
* writes the end of the chosen *local* day expressed in UTC (see its docs — the
* provider applies UNTIL coarsely), so displaying it means converting back into
* the device zone first. Reading the leading digits raw showed the wrong day for
* zones behind UTC.
*/
class RecurrenceTextTest {
private val berlin = ZoneId.of("Europe/Berlin")
private val losAngeles = ZoneId.of("America/Los_Angeles")
private val utc = ZoneId.of("UTC")
@Test
fun `UTC form converts back to the picked day west of UTC`() {
// toRRule("2026-12-31", America/Los_Angeles) → 23:59:59 local = 07:59:59Z
// on 1 Jan. Reading the digits raw would show 1 Jan 2027.
assertThat(untilLocalDate("20270101T075959Z", losAngeles))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `UTC form converts back to the picked day east of UTC`() {
// Berlin in December is UTC+1: 23:59:59 local = 22:59:59Z the same day.
assertThat(untilLocalDate("20261231T225959Z", berlin))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `UTC form is unchanged at UTC itself`() {
assertThat(untilLocalDate("20261231T235959Z", utc))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `summer offset is honoured, not a fixed one`() {
// Berlin in July is UTC+2, so the same local end-of-day lands at 21:59:59Z.
assertThat(untilLocalDate("20260801T215959Z", berlin))
.isEqualTo(LocalDate.of(2026, 8, 1))
}
@Test
fun `date-only form is already local and passes through`() {
assertThat(untilLocalDate("20261231", losAngeles))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `floating date-time form uses its date as-is`() {
// No trailing Z, so it isn't an instant — no conversion may be applied.
assertThat(untilLocalDate("20261231T235959", losAngeles))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `garbage returns null rather than throwing`() {
assertThat(untilLocalDate("", berlin)).isNull()
assertThat(untilLocalDate("nonsense", berlin)).isNull()
assertThat(untilLocalDate("2026", berlin)).isNull()
assertThat(untilLocalDate("20261340", berlin)).isNull()
}
}

View File

@@ -0,0 +1,158 @@
package de.jeanlucmakiola.calendula.ui.edit
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.Dispatchers
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
/**
* Focuses on the save-time branch that decides between an in-place update and a
* calendar *move* (copy+delete). The provider-level move itself is verified
* on-device; here the fake records which repository call the ViewModel chose.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EventEditViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
private fun cal(id: Long): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
)
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
),
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
)
// Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
// last-used calendar) completes under advanceUntilIdle instead of on a real
// IO thread the virtual clock can't observe.
private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
),
)
private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
),
)
private fun viewModel(
tempDir: Path,
fake: FakeCalendarDataSource,
): EventEditViewModel {
val p = prefs(tempDir)
val s = settings(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
return EventEditViewModel(repo, p, s, dispatcher)
}
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
@Test
fun `changing the calendar routes the save through a move, not an update`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L))
eventDetailResult = { detail(calendarId = 1L) }
nextInsertId = 99L
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
vm.setCalendar(2L)
vm.save()
advanceUntilIdle()
assertThat(fake.movedEvents).hasSize(1)
assertThat(fake.movedEvents.single().eventId).isEqualTo(42L)
assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L)
assertThat(fake.updatedEvents).isEmpty()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
job.cancel()
}
@Test
fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L))
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
vm.setCalendar(2L)
vm.save()
advanceUntilIdle()
// No AwaitingScope park: a move is inherently whole-series.
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
assertThat(fake.movedEvents).hasSize(1)
job.cancel()
}
@Test
fun `editing a recurring event without moving still asks for the scope`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L))
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
vm.setTitle("Renamed")
vm.save()
advanceUntilIdle()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope)
assertThat(fake.movedEvents).isEmpty()
job.cancel()
}
}

View File

@@ -0,0 +1,89 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class WidgetScaleTest {
@Test
fun `the on-device calibration points map to their tiers`() {
// The two sizes measured on-device: the compact widget stays COMPACT (the
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
// which read as too big on a phone (#51).
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
}
@Test
fun `a full-width widget at ordinary height still scales up`() {
// The regression the height cap used to cause: widening the widget without
// also making it unusually tall is *the* resize #51 reports, and it must
// reach the tier its width earned. Three cells tall is about 270dp.
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `width buckets into the four tiers`() {
// Tall enough that the height cap never binds, isolating the width rule.
val h = 500.dp
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `extra height never raises the tier`() {
// Height decides how many rows are visible, not how big they are: a tall,
// narrow widget wants more events, not bigger text.
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
}
@Test
fun `only a genuinely squashed widget is stepped back down`() {
// Same (wide) width, shrinking height. The cap exists to stop oversized type
// surviving in a one- or two-row sliver — it must not fire at normal heights.
// Heights are gross; the cap works on height minus 60dp of chrome, so the
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
val wide = 378.dp
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
}
@Test
fun `the tier is monotonic in both axes`() {
// Growing a widget must never make its type smaller. Sweeps the whole
// plausible range rather than spot-checking, so a future threshold edit
// can't accidentally invert a step.
val widths = (110..900 step 7).map { it.dp }
val heights = (110..900 step 7).map { it.dp }
widths.forEach { w ->
heights.zipWithNext { shorter, taller ->
assertThat(scaleFor(DpSize(w, taller)))
.isAtLeast(scaleFor(DpSize(w, shorter)))
}
}
heights.forEach { h ->
widths.zipWithNext { narrower, wider ->
assertThat(scaleFor(DpSize(wider, h)))
.isAtLeast(scaleFor(DpSize(narrower, h)))
}
}
}
}

View File

@@ -0,0 +1,121 @@
package de.jeanlucmakiola.calendula.widget.agenda
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.widget.WidgetScale
import de.jeanlucmakiola.calendula.widget.scaleFor
import org.junit.jupiter.api.Test
class AgendaScaleTest {
// --- the "default size is unchanged" regression guard ---------------------
@Test
fun `every default placement width stays COMPACT`() {
// Ties the guarantee to the provider XML's declared 3-cell default rather
// than to one measured launcher: the whole band a default placement can
// land in must bucket to COMPACT, or a freshly placed widget silently
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
val band = AGENDA_DEFAULT_WIDTH_BAND
var w = band.start
while (w <= band.endInclusive) {
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
w += 1.dp
}
}
@Test
fun `COMPACT metrics equal the widget's original constants`() {
// If this fails, a default-sized agenda widget no longer looks as it did.
val m = metricsFor(WidgetScale.COMPACT)
assertThat(m.title).isEqualTo(16.sp)
assertThat(m.dayHeader).isEqualTo(13.sp)
assertThat(m.eventTitle).isEqualTo(14.sp)
assertThat(m.eventTime).isEqualTo(12.sp)
assertThat(m.placeholder).isEqualTo(14.sp)
assertThat(m.message).isEqualTo(14.sp)
assertThat(m.stripeH).isEqualTo(36.dp)
assertThat(m.iconImage).isEqualTo(22.dp)
assertThat(m.iconBox).isEqualTo(40.dp)
assertThat(m.rowVPad).isEqualTo(4.dp)
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
}
@Test
fun `the derived text indent matches the original hardcoded inset`() {
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
// day headers and the placeholder line stop aligning with event titles.
assertThat(TEXT_INDENT).isEqualTo(19.dp)
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
}
// --- the ramp ------------------------------------------------------------
@Test
fun `sizes are non-decreasing across the tiers`() {
val tiers = WidgetScale.entries.map(::metricsFor)
tiers.zipWithNext { small, big ->
assertThat(big.title.value).isAtLeast(small.title.value)
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
assertThat(big.message.value).isAtLeast(small.message.value)
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
}
}
@Test
fun `the event title keeps its lead over the time line`() {
// The secondary line steps more slowly on purpose; if it ever caught up the
// row would lose its hierarchy.
WidgetScale.entries.map(::metricsFor).forEach { m ->
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
}
}
@Test
fun `no tier grows type more than half again over the baseline`() {
// Guards against a future edit turning "more readable" into "absurd".
val base = metricsFor(WidgetScale.COMPACT)
val top = metricsFor(WidgetScale.XLARGE)
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
}
// --- font scale ----------------------------------------------------------
@Test
fun `the stripe tracks the system font scale`() {
// The stripe is Dp, the text beside it is sp: without this the two diverge
// at large accessibility font settings and the stripe under-runs the row.
val m = metricsFor(WidgetScale.COMPACT)
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
}
@Test
fun `scaling for the default font scale changes nothing`() {
val m = metricsFor(WidgetScale.LARGE)
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
}
@Test
fun `font scaling leaves the sp sizes alone`() {
// Glance already applies the font scale to sp; scaling them here too would
// double-count it.
val m = metricsFor(WidgetScale.REGULAR)
val scaled = m.scaledForFont(1.3f)
assertThat(scaled.title).isEqualTo(m.title)
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
}
}

View File

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

View File

@@ -0,0 +1,74 @@
### Added
- Show raw calendar colours. Calendula normally softens each calendar and event
colour toward a theme-fitting pastel so harsh sync colours read well on both
light and dark; a new **Soften calendar colours** setting (Settings → Design,
on by default) lets you turn that off and paint the exact colours your calendar
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
@leonp5 for the report ([#36]).
- Readable titles on dark event colours. An event bar's title now shows in white
on a dark colour and near-black on a light one, chosen automatically from the
colour's brightness, so a deep blue or purple event is legible at a glance in
the busy Week and Month views instead of dark-on-dark. This applies whether or
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
- A custom snooze duration. The **Snooze duration** setting (Settings →
Notifications) gains a **Custom…** option next to the minute presets: pick any
amount and switch between minutes and hours, so a snoozed reminder comes back
after exactly the delay you want instead of only a preset one ([#40]).
- Move an event to another calendar. When editing an existing event, the
calendar row is now tappable — pick a different calendar and saving moves the
event across, instead of having to delete it and recreate it elsewhere.
Recurring series move as a whole, keeping their individually-edited and
cancelled occurrences, and any reminders and guests come along too. A calendar
can't simply be reassigned underneath an event, so Calendula recreates it on
the target and removes the original — the same approach other calendar apps
take. Thanks to @prismplex for the suggestion ([#39]).
- Open an event straight into the edit form from another app. Calendula already
answered the "new event" and "open this event" hand-offs from other apps and
widgets; it now also answers the "edit this event" one, so an assistant, task
app, or widget can send an existing event to Calendula and land on its edit
screen rather than the read-only details. A hand-off with no event attached
opens the same prefilled create form as "new event". Calendula also recognises
a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too.
- Keep today at the top of the agenda. A new **Always show today** setting
(Settings → Agenda, on by default) anchors today as the first entry in both the
Agenda screen and its home-screen widget even once nothing is left today —
under today's header a "No more events today" note appears — so the first
events you see are clearly today's rather than a future day's. Turn it off to
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
- Duplicate an event. The event details now carry a **Duplicate** action that
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
a one-off like a shift or an appointment can be recreated by just changing the
day and time instead of re-typing every field. The copy keeps the original's
time, and its title, location, notes, colour, guests and reminders come along;
it's saved as its own single event (any repeat is left off — add one in the
editor if you want it). Duplicate works from read-only calendars too, dropping
the copy into a writable one. Thanks to @internet-rando for the suggestion
([#52]).
- French and Polish, in early form. Calendula has started speaking French and
Polish, both contributed as community translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are partway there, so untranslated parts still show in English until they
fill out — you can already pick either under Settings → Language or in Android's
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
(Polish) for getting them started; help finishing them is very welcome.
### Fixed
- Reminders for events on another day no longer read as if they were today. A
reminder fired ahead of time — say, the day before — used to show only the
event's time, making it look like it was happening now. The notification now
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
week, or the date for anything further out. Thanks to @moonj for the report
([#46]).
- Agenda dates now read in your locale's format. The agenda's range bar and its
day headers used a fixed day-month-year layout — and the range span even mixed
two orders (e.g. "15 Jul Aug 13, 2026") — instead of following your language's
conventions. Dates across the agenda and its widget now use your locale's own
field order, matching the rest of the app. The range bar also no longer repeats
the range's name from the selector button beside it, showing just the dates.
- Calendar gutters line up with the menu button. The Month view's week-number
column, and the Week and Day views' hour labels, sat a few pixels left of the
hamburger menu above them; they now line up with it. In Month view the day
cells also sit squarely under their weekday letters.

View File

@@ -0,0 +1,37 @@
### 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]).
- Show the app as "Calendar" in your launcher. A new App name setting
(Settings → Appearance) switches the launcher label from "Calendula" to the
generic "Calendar" for anyone who prefers it — handy on launchers that can't
rename apps themselves. Only the launcher name changes; your home-screen icon
may move to a new spot after switching ([#44]).
### 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]).