Compare commits

..

177 Commits

Author SHA1 Message Date
69460bc35a Merge pull request 'Translations update from Weblate' (!45) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #45
2026-06-30 21:48:10 +00:00
Weblate
bf10deaf5c Update translation files
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 7m20s
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:44 +00:00
Weblate
b9e861af62 Added translation using Weblate (Russian) 2026-06-30 21:40:44 +00:00
Weblate
2eadda937b Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:44 +00:00
Weblate
71b90fa184 Added translation using Weblate (Portuguese) 2026-06-30 21:40:44 +00:00
Weblate
4e9cf88bd4 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:44 +00:00
Weblate
b999202629 Added translation using Weblate (Polish) 2026-06-30 21:40:44 +00:00
Weblate
6f0e13a4a2 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:44 +00:00
Weblate
9ef7b33800 Added translation using Weblate (Italian) 2026-06-30 21:40:43 +00:00
Weblate
d04b431f2c Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:43 +00:00
Weblate
dbeeadff80 Added translation using Weblate (French) 2026-06-30 21:40:43 +00:00
Weblate
f4a169ef5f Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:43 +00:00
Weblate
604099b3f2 Added translation using Weblate (Spanish) 2026-06-30 21:40:43 +00:00
Anonymous
a3e6541512 Translated using Weblate (German)
Currently translated at 90.6% (329 of 363 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-06-30 21:40:42 +00:00
fb2a40114a Merge pull request 'release: v2.12.0 — past-event display + week-view title wrap' (!51) from release/v2.12.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 10m22s
Renovate / renovate (push) Successful in 1m9s
Reviewed-on: #51
2026-06-28 20:43:48 +00:00
e1f79df67a release: cut v2.12.0 changelog and fastlane notes
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m9s
Move the accumulated [Unreleased] section under [2.12.0] — 2026-06-28 and
regenerate fastlane/.../changelogs/21200.txt from it (scripts/
sync_changelog_to_fastlane.sh), so the official F-Droid listing and the
Gitea release notes both cover the release's two changes: the past-event
display settings (agenda screen + widget, month/week dimming) and the
overlapping-event title wrapping. versionName is already 2.12.0/21200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:33:58 +02:00
e258a4a7f2 feat(widget): apply the past-event display setting to the agenda widget
Issue #12 asks for hiding finished events in the agenda view *and* its
home-screen widget. The in-app screen already honours the Past events
setting (Show / Dim / Hide); extend the same setting to the "Upcoming"
agenda widget.

The widget loads its events unfiltered and applies the mode reactively in
the composition against a captured "now" — mirroring how the range is read
from per-instance Glance state — so toggling the setting reflects on a live
widget without relying on provideGlance's data preamble re-running. Hiding
drops finished events and any day they empty; dimming fades the colour
stripe and lowers the title's emphasis (Glance has no generic alpha
modifier). setPastEventDisplay now pushes the mode into each widget's Glance
state and recomposes, like setAgendaWidgetRange.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:29:19 +02:00
3f27a47d1e docs(changelog): credit Codeberg #12 (@ptab) for the past-events feature
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:21:24 +02:00
feb87a1e73 docs(changelog): record the week-view title-wrap change for v2.12.0
The overlapping-title fix already shipped its F-Droid note in
fastlane/.../21200.txt but was never added to CHANGELOG.md, so the
release-cut sync (which regenerates the fastlane note from the changelog's
version section) would have dropped it. Add it under [Unreleased] › Changed
alongside the past-event display settings, and define the missing [#13] link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:16:37 +02:00
28e464a616 merge: optionally dim or hide past events (feat/past-event-display) into v2.12.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt
2026-06-28 22:11:14 +02:00
93df6d6d62 release: v2.12.0 — wrap event titles in week view
Bump versionName to 2.12.0 (versionCode 21200) and add the changelog.
Merging this into main triggers release.yaml, which builds, signs,
publishes and mints the v2.12.0 tag + Gitea release.

Closes #13
2026-06-28 22:01:26 +02:00
49ddb9437b feat(week): wrap event title to fill slim overlapping blocks
Overlapping events split a day column into narrow lanes where the title
was clipped to one or two characters. Mirror Google Calendar: drop the
time label on overlapping (multi-lane) blocks and wrap the title across
as many lines as the block height allows, so the full title stays
readable without opening the event.

Closes #13
2026-06-28 21:58:22 +02:00
e1cf00999d feat(views): optionally dim or hide past events
Adds two independent display settings under Settings › Appearance, both
defaulting to the current behaviour (off) so nothing changes until a user
opts in:

  • Agenda › "Past events" (Show / Dim / Hide) — events that already ended
    today can be left as-is, faded, or dropped from the list. Hiding also
    removes any day left empty, falling back to the empty state. Re-evaluated
    each minute so rows fade/fall away as they end while the screen is open.

  • Calendar › "Dim completed events" — a separate toggle that fades finished
    events in the month and week grids, kept independent of the agenda setting.

An event counts as completed once its end is at or before now (in-progress
events are never dimmed; all-day events only after their day is fully over),
via a shared EventInstance.hasEnded(now). The grids read the cut-off through
a new LocalDimCutoff CompositionLocal (mirroring LocalShowHourLines) so only
the event chips recompose on the per-minute tick, and only while dimming is on.

Also adds an "Agenda" section header so the agenda rows stand apart in the
now-busier Appearance screen, and documents the feature in the changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:48:19 +02:00
2d0d707a7d Merge pull request 'release: v2.11.2 — auto-focus event title (#10)' (!50) from release/v2.11.2 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 10m29s
Reviewed-on: #50
2026-06-28 10:55:24 +00:00
de1fe31223 feat(event-form): auto-focus the title on a new event, optionally (#10)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 7m11s
Opening the new-event form now puts the cursor in the title field and raises the
keyboard, so the user can type the name straight away instead of tapping the
field first (issue #10). On by default — most events get a title — with a new
"Focus title on new event" switch in Settings → New event form to turn it off.

Only the create form auto-focuses: editing an existing event and opening a
prefilled/imported form never grab focus (guarded by !isEditing && title blank).

- SettingsPrefs: autofocusEventTitle (booleanPreferencesKey), default true.
- Plumbed through SettingsViewModel/UiState (settings switch) and
  EventEditViewModel/UiState (read by the form).
- EventEditScreen: a FocusRequester on the title InlineField, requested once per
  open from a LaunchedEffect when the guard holds.
- Strings (en + de), unit test for the new pref default/round-trip.

Bumps to 2.11.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:47:47 +02:00
273cfce969 Merge pull request 'release: v2.11.1 — register calendar intent filters (#9)' (!49) from release/v2.11.1 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 11m28s
Reviewed-on: #49
2026-06-28 10:32:42 +00:00
b6a45b7264 fix(intents): register calendar intent filters so Calendula can be the default calendar (#9)
All checks were successful
CI / ci (pull_request) Successful in 7m51s
Calendula didn't declare the intent filters launchers and the system use for
calendar actions, so it never appeared in the "default calendar app" chooser —
on every platform, not just GrapheneOS (issue #9). Android exposes no API for an
app to set itself default, so registering these filters is the only way users can
pick it from the system picker.

Adds to MainActivity:
- MAIN + APP_CALENDAR — the "open the calendar app" action the OS/launchers use.
- VIEW on content://com.android.calendar/time/<epochMillis> and the time/epoch
  mime type — a launcher/clock date tap. The provider's time Uri is parsed into a
  LocalDate and opened on the day view, rooted over the default home view (a new
  sourceless WidgetNavRequest.OpenDate). The .ics import path now ignores the
  calendar provider host so a date tap isn't mistaken for a file to import.

Bumps to 2.11.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:15:56 +02:00
c7ef52e426 Merge pull request 'release: v2.11.0' (!48) from release/v2.11.0 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) Successful in 6m55s
Reviewed-on: #48
2026-06-27 21:49:32 +00:00
b1151b6516 refactor(translations): move Help translate into the App language page
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m58s
Per review, it shouldn't be a top-level Settings entry: the "Help translate"
link now sits at the top of the full-screen App language picker (OptionPicker
gains an optional header slot). Updated README + changelog wording to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:18:06 +02:00
1664625bc5 feat(translations): add "Help translate" link to the Weblate engage page
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m39s
Invite community translations: a new Settings > Help translate row (next to
App language) opens the project's Weblate engage page, plus a Translations
section in the README. Documented in the 2.11.0 changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:10:06 +02:00
e8b87446f7 docs(changelog): document settings redesign + auto-backup for 2.11.0
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 9m33s
Add the Calendars manager redesign, Notifications reorg, QS-tile move,
event-form field icons, and automatic local-calendar backup (#8) to the
2.11.0 changelog; sync the F-Droid per-version changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:57:44 +02:00
dc478516cd fix(backup): stop orphaned backup work when disabled
Turning automatic backup off only cancelled the periodic work, so a
run-now that kept failing (e.g. its folder was deleted) retried forever
and spammed failure notifications.

- Cancel the immediate "run now" work too when backup is disabled.
- Worker no-ops (no retry) when the toggle is off, so already-queued work
  can't revive itself.
- Reconcile scheduled work against saved settings on every app launch —
  re-arms after a reinstall and clears orphaned work once backup is off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:35:58 +02:00
8945b877b2 Merge pull request 'feat(backup): automatic periodic .ics export of local calendars (#8)' (!47) from feat/auto-backup into release/v2.11.0
Reviewed-on: #47
2026-06-27 20:22:22 +00:00
1424f0ffc0 feat(backup): connect one-time export + automatic backup into one card
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m44s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:17:24 +02:00
548e581554 feat(backup): automatic periodic .ics export of local calendars (#8)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m47s
Scheduled one-way export of local calendars to a user-chosen folder,
overwriting calendula-backup.ics each run — the manual backup, automated.
Not a sync; stays INTERNET-free (provider reads + local file write).

- WorkManager periodic job (deps: work-runtime-ktx, documentfile); worker
  pulls collaborators via a Hilt @EntryPoint, so no custom WorkerFactory
  wiring. First periodic run is delayed one interval so it can't race the
  immediate "run now" feedback run; the writer also overwrites the canonical
  file and cleans up any "(1)" duplicates from earlier races.
- SettingsPrefs: enabled, interval (minutes, floored at 30), folder Uri,
  last-run status; persisted SAF write grant.
- UI in Calendars > Backup: toggle, folder picker, amount+unit interval
  dialog, last-run status line. Notifies after repeated failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:13:55 +02:00
a6ae4d7e1d Merge pull request 'feat(settings): settings UX polish + Calendars manager redesign' (!46) from feat/settings-improvements into release/v2.11.0
Reviewed-on: #46
2026-06-27 19:47:43 +00:00
614f4f2d75 feat(calendars): account header overflow menu + restyle
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m9s
Replace the per-account toggle switch and nested manage row with a single
trailing overflow (⋮) menu holding both account-level actions:
- "Enable all" / "Disable all" — toggle every calendar in the group
- "Manage in app" (synced) / "Add calendar" (local)

The dropdown is styled to fit: rounded corners, a distinct floating
surface (surfaceContainerLowest + lifted shadow) so it stands clear of
the cards, and a divider separating the two actions. Shortened the manage
label to "Manage in app" and dropped the now-unused account a11y strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:20 +02:00
e6736b049a feat(settings): settings UX polish + Calendars manager redesign
Settings:
- Event-form field rows show each field's own icon (shared
  EventFormFieldVisuals, reused by the editor and settings)
- Move "Add Quick Settings tile" to a top-level Settings hub row
- Notifications: reliable-delivery + snooze moved above the
  per-calendar block, which now folds behind one expandable section

Calendars manager:
- Local and synced calendars now use one collapsible group card
- Source-branded headers: each account shows its app's launcher icon
  (Google Calendar, DAVx5, …) loaded from PackageManager; local shows
  a device chip
- Per-account toggle-all switch (CalendarsViewModel.setAccountDisabled)
- Management action ("Add calendar" / "Manage in app") is a nested row
  inside the expanded group; removed the dead "Add account" row
- A fully deactivated account dims its header, not just the switch
- Group headers use the normal row colour; their options sit one tone
  darker (GroupedRow gains an optional container colour)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:39:07 +02:00
301904be6d docs(changelog): note agenda range bar and Appearance tidy-up in 2.11.0
Expand the #4 entry to cover the in-agenda range bar (date header + session
range switch) and add a Changed note for the Appearance regrouping and the
friendlier agenda empty state. Regenerate the F-Droid changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:11:29 +02:00
3ca7200624 Merge pull request 'feat(agenda): limit how far ahead the agenda screen and widget show (#4)' (!44) from feat/agenda-range-limit into release/v2.11.0 2026-06-27 15:08:34 +00:00
c30a153552 fix(widget): drive agenda range via reactive Glance state
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m22s
The agenda widget loaded and sliced its data in the provideGlance preamble
and captured it as a non-reactive local. updateAll() reliably recomposes a
live Glance session but does not reliably re-run that preamble, so a range
change was redrawn against the stale slice (intermittently — only when no
session was alive did it pick up the new range). This is the same platform
limitation the month widget already works around.

Mirror that pattern: load the widest selectable window once, store the range
in per-instance Glance state (AGENDA_RANGE_KEY), read it reactively via
currentState, and slice in the composition. The settings setter writes the
state into each instance and recomposes, so a range change now reflects via
plain recomposition regardless of session lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:03:48 +02:00
bb92d02000 fix(widget): refresh from setter, serialized with a mutex
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m29s
The debounced observer was unreliable: drop(1) plus the ViewModel lifecycle
could swallow a change after re-entering the app, so a single range change
sometimes didn't refresh the widget. Refresh directly from the settings
setters instead (always fires on a real change), serialized through a mutex
so a rapid flip-and-flip-back can't run two updateAll calls at once and
strand the widget on the intermediate value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:44:52 +02:00
cd703e9d54 fix(widget): debounce widget refresh on pref change
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m38s
Refreshing the widget directly from the setter raced when a setting was
flipped and flipped back quickly: two concurrent updateAll calls could
coalesce around a stale read and leave the widget on the intermediate value.
Observe the agenda-widget-range and week-start prefs instead and push a
single debounced updateAll once changes settle, so the widget always
converges to the final value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:37:57 +02:00
f27b0e269d fix(widget): refresh agenda widget when its range (or week start) changes
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m18s
The agenda widget only re-read agendaWidgetRange on the next data-change /
midnight / periodic refresh (or re-placement), so a settings change appeared
to do nothing until then. Push an updateAll from the settings setters:
the agenda widget on an agenda-widget-range change, and both widgets on a
week-start change (month weekday header + the agenda widget's "this week").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:30:27 +02:00
0f31981d26 refactor(agenda): one toggle for the whole range bar
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m43s
Consolidate the separate "range title" and "range button" settings into a
single "Range bar" toggle, now that the header and switcher live on one bar.
On by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:25:46 +02:00
6471c48528 refactor(agenda): put range header and pill on one bar
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m46s
Move the session range pill from the bottom-left corner onto the same top
bar as the "Showing all upcoming events for …" header — header on the left,
pill on the right, each still independently toggleable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:22:33 +02:00
f8a2569831 feat(agenda): warmer empty state + "upcoming" wording
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m30s
Reword the range header to "Showing all upcoming events for". Replace the
dry empty state ("Nothing scheduled" + subtitle) with a single warmer line
("You're all caught up"), drop the subtitle, and switch the icon to a
coffee cup. The empty title is shared with the agenda widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:20:33 +02:00
6ba7f38887 feat(agenda): range header banner + toggles for banner and pill
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m29s
Move the "Showing all events for <window>" line out of the picker and into
the agenda view as an always-visible header above the list. Add two
settings (Agenda group, both ON by default) to toggle the range header
banner and the bottom-left range pill independently. The picker keeps its
two-list grouping but no longer carries the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:08:23 +02:00
366059b012 feat(agenda): split range picker into two lists with a concrete header
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m27s
Group the range options into calendar-aligned (today / this week / this
month) and rolling (next 7 / 30 days / custom) lists. Add a header showing
the concrete span currently in effect — a single date for Today, the month
and year for This month, otherwise a start–end span — so it's clear what
the agenda is showing. The header appears only where a window is supplied
(the agenda pill), not in Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:59:56 +02:00
3cf1439850 feat(agenda): bottom-left pill to override the range for the session
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m4s
Add a tonal pill in the agenda's bottom-left corner showing the current
range; tapping it opens the existing AgendaRangePicker as a session-only
override. The override lives in AgendaViewModel (in-memory), so it survives
view switches and rotation but resets to the saved default when the app is
relaunched. The pill fills with the primary container while an override is
active, and a hint in the picker spells out the temporary nature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:50:18 +02:00
b2bfdc0f42 refactor(settings): regroup Appearance into theme / calendar / agenda
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m0s
Drop the uniform two-per-card grouping for meaningful sections: theme &
colour (2), calendar — default view, week start, time format, hour lines
(4), and agenda (2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:36:19 +02:00
7c9830deda Merge release/v2.11.0 into feat/agenda-range-limit
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m26s
Integrate #3/#5/#6 (week-start, time-format, hour-lines) into the agenda
range branch. SettingsViewModel folds all five view prefs into one combine;
WidgetData keeps the range-based window and adds the resolved is24Hour.

Also, in the same Appearance section:
- regroup the rows into four titled-by-spacing groups (theme & colour /
  calendar layout / timeline display / agenda) instead of one long card.
- add an explanatory description line under the title of each agenda-range
  picker (PickerDescription).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:31:44 +02:00
5e98059388 Merge pull request 'feat(timeline): optional hour separator lines in week & day view (#5)' (!43) from feat/timeline-hour-lines into release/v2.11.0
Reviewed-on: #43
2026-06-27 13:18:26 +00:00
53c0482760 Merge pull request 'feat(settings): add 12/24-hour time format toggle (#6)' (!42) from feat/time-format-toggle into release/v2.11.0
Reviewed-on: #42
2026-06-27 13:18:05 +00:00
f8efa4d3e4 Merge branch 'feat/time-format-toggle' into feat/timeline-hour-lines
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m51s
2026-06-27 14:54:38 +02:00
93cc264fbf Merge remote-tracking branch 'origin/release/v2.11.0' into feat/time-format-toggle
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 9m45s
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values/strings.xml
2026-06-27 14:53:45 +02:00
585cdac4ab Merge pull request 'feat(settings): allow week to start on any day (#3)' (!41) from feat/week-start-any-day into release/v2.11.0
Reviewed-on: #41
2026-06-27 12:47:45 +00:00
9994e8c534 chore(release): prepare v2.11.0
Bump versionName to 2.11.0 (versionCode 21100) and add the 2.11.0
changelog: any-day week start (#3), 12/24-hour time format (#6),
optional timeline hour lines (#5), and agenda range limits (#4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:34:48 +02:00
766c2ffcf8 feat(agenda): add calendar-aligned "this week"/"this month" ranges
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 11m22s
Extend AgendaRange with calendar-aligned windows alongside the rolling
ones: ThisWeek runs through the end of the current week (respecting the
week-start preference — a Monday start means everything before next
Monday), ThisMonth through the last day of the current month. dayCount now
takes the anchor day and week-start; the agenda screen and widget resolve
the week-start preference and pass it through. Rolling options relabelled
("Today", "Next 7 days", "Next 30 days") to read distinctly from the new
calendar-aligned ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:25:59 +02:00
add88fbadf feat(agenda): limit how far ahead the agenda screen and widget show
Add independent agendaScreenRange and agendaWidgetRange preferences, each a
rolling window: 1 day / 1 week / 1 month / custom 1–365 days (default Month).
Rolling (not calendar-aligned) so the span never degenerates near a period
boundary. The in-app Agenda screen and the agenda widget each read their own
setting, configured via a new AgendaRangePicker with an inline custom-days
editor.

Closes #4 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:15:46 +02:00
09a1aecb76 feat(timeline): optional hour separator lines in week & day view
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 11m25s
Add a showHourLines preference (default off — the existing clean look).
When on, the week and day timelines draw a faint outline-variant line at
each hour boundary, sitting over the column background but beneath event
blocks. The toggle is provided app-wide via LocalShowHourLines and applied
through a reusable hourSeparatorLines() modifier.

Closes #5 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:07:09 +02:00
c91608e48a feat(settings): add 12/24-hour time format toggle
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 11m16s
Add a TimeFormatPref (Auto/12h/24h) preference. Auto follows the device's
24-hour system setting; the others force a clock app-wide. The resolved
convention is provided once at the app root via LocalUse24HourFormat, so
every in-app time label reads it without per-screen plumbing.

A shared, pure TimeFormat helper (formatTimeOfDay / formatMinuteOfDay /
formatHourLabel / timeOfDayFormatter) is now the single source for all
time-of-day rendering. Routing every site through it also fixes a
pre-existing inconsistency: the week/day timeline gutters and the agenda
screen + widget hard-coded 24h, while event detail/edit/search/reminders
followed the locale — so a 12h-locale user previously saw mixed formats.

Covered: week & day timelines (gutter + event blocks), agenda screen and
widget, event detail/edit, search, and reminder notifications.

Closes #6 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:02:22 +02:00
47e8036000 feat(settings): allow week to start on any day
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m7s
Replace the AUTO/MONDAY/SUNDAY week-start enum with a sealed type
(Auto + Day(DayOfWeek)) so users can pick any of the seven days as the
first day of the week. The picker now lists "Automatic" plus all seven
localised weekday names; labels come from java.time display names rather
than per-day string resources.

Stored values round-trip by DayOfWeek.name, so the legacy MONDAY/SUNDAY
preferences migrate transparently. Garbage values fall back to Auto.

Closes #3 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:45:01 +02:00
07a8faf2df Merge pull request 'release: v2.10.0' (!40) from release/v2.10.0 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) Successful in 6m11s
Reviewed-on: #40
2026-06-25 12:53:12 +00:00
54b059e744 chore(release): prepare v2.10.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:31:24 +02:00
d0c01ab8c4 Merge pull request 'feat(calendars): in-app enable/disable for calendars' (!38) from feat/disable-calendar into release/v2.10.0
Reviewed-on: #38
2026-06-25 12:28:31 +00:00
bf10fee19f Merge remote-tracking branch 'origin/release/v2.10.0' into feat/disable-calendar
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m57s
# Conflicts:
#	CHANGELOG.md
2026-06-25 14:23:07 +02:00
2e88c40eff Merge pull request 'feat(about): add Ko-fi support link' (!39) from feat/kofi-support into release/v2.10.0
Reviewed-on: #39
2026-06-25 12:21:37 +00:00
26e538a9ba docs(changelog): note Ko-fi support link
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 9m16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:07:40 +02:00
814daa8151 docs(changelog): note in-app calendar enable/disable
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m49s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:06:39 +02:00
450fc7f7af Merge remote-tracking branch 'origin/main' into feat/disable-calendar 2026-06-25 14:05:45 +02:00
be6d10d126 feat(calendars): add app-side enable/disable for calendars
Introduce a second, heavier visibility level above the per-view hide
filter. A disabled calendar is removed from the app's surfaces entirely —
its events drop out of all views and search, and it disappears from the
drawer filter list, the event-form calendar picker and the import target
picker. It stays listed only in Settings → Calendars, where a per-row
switch toggles it, so it can always be brought back.

- CalendarPrefs: disabledCalendarIds set + setter, mirroring the hidden
  set (DataStore comma-separated string); never touches the system
  VISIBLE/SYNC_EVENTS flags, so it's app-local and reversible.
- CalendarRepositoryImpl: instances()/searchEvents() exclude
  calendarId ∈ (hidden ∪ disabled). distinctUntilChanged() on instances
  collapses the transient duplicate emission both DataStore-derived sets
  produce when either is toggled.
- FilterViewModel: drop disabled calendars from the drawer filter list.
- EventEditViewModel: exclude disabled from writableCalendars; a
  last-used preselect on a now-disabled calendar falls back to the first
  remaining writable one.
- ImportViewModel: exclude disabled from the import target list.
- CalendarsScreen/ViewModel: per-row enable/disable Switch on both the
  local and synced groups; disabled rows render dimmed (new GroupedRow
  `dimmed` flag) while keeping the toggle live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:55:10 +02:00
a1e8c05c7b docs(roadmap): mark v2.8.0 work shipped, capture disable-calendar
Update roadmap to reflect v2.8.0 releases (attendee editing, full-text
search, QS tile, now-line, contact pickers) and the week-numbers-in-month
rejection; capture the in-app 'disable a calendar' feature plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:39:01 +02:00
cecd0c2234 docs(readme): revert F-Droid badge to height 56
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:34:27 +02:00
43eb5e23e8 docs(readme): enlarge F-Droid badge to height 80
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:33:41 +02:00
946addbbcd docs(readme): use official 'Support me on Ko-fi' beige badge
Swap the Buy-me-a-coffee pill for Ko-fi's beige support badge, matched
to the F-Droid badge height (56).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:32:21 +02:00
8a63aa86a7 Update README.md 2026-06-25 11:28:44 +00:00
0ddbbb11e8 Update README.md 2026-06-25 11:28:19 +00:00
575b1c7eb7 Update ReadMe.md 2026-06-25 11:25:55 +00:00
dd61e0ed37 docs(readme): shrink Ko-fi button for visual balance
The solid Ko-fi pill read heavier than the F-Droid badge at equal
height; drop it to 46 so the two feel balanced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:23:13 +02:00
376ad5f394 docs(readme): put F-Droid and Ko-fi buttons inline at equal height
Both at height 56 on one line — equal heights align on the baseline,
so no table/border workaround is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:19:57 +02:00
8a9e6f0ee8 docs(readme): stack F-Droid and Ko-fi buttons, drop bordered table
The table centered them but Gitea draws cell borders we can't strip.
Stack the two badges on separate centered lines instead — no borders,
each keeps its own size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:09:29 +02:00
913b49e962 docs(readme): vertically center the F-Droid and Ko-fi buttons
Wrap both badges in a table row so the different-height buttons
align on a shared midline instead of the text baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:08:12 +02:00
96f4d4dc84 docs(readme): restore F-Droid badge to original height
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:07:04 +02:00
f4789c535e docs(readme): align Ko-fi button with F-Droid badge
Use Ko-fi's raster PNG button (the SVG was ignored and rendered
oversized) and match both buttons to height 56 so they sit inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:06:03 +02:00
00220c5f0e docs(readme): use official Ko-fi button next to F-Droid badge
Replace the small shields Ko-fi badge with Ko-fi's official
"Support me" button, placed beside the Get it on F-Droid badge
as a peer call-to-action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:04:15 +02:00
b7a5f16c84 docs(readme): replace inline sections with doc links
Move the Building section into docs/BUILDING.md and collapse Building,
Architecture, and Roadmap into a compact Documentation block linking to
the dedicated docs, keeping the README focused on what/install/support.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:56:26 +02:00
2c38912834 docs(readme): surface Ko-fi + official F-Droid, tidy install
- Move support to the top: Ko-fi badge in the header badge row,
  drop the redundant bottom Support section
- Add "Get it on F-Droid" button now that Calendula is on the
  official repo
- Restructure Install: lead with official F-Droid, keep the
  self-hosted repo as the faster-updates lane

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:53:01 +02:00
a9c67a5827 feat(about): add Ko-fi support link
Add an optional way for users to support development:

- About card: tonal "Support development" button opening ko-fi.com
  via ACTION_VIEW (no INTERNET permission), EN + DE strings
- F-Droid metadata: Donate field in self-hosted and official drafts
- README: short Support section with the Ko-fi link

No perks/rewards attached — kept as a plain donation link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:49 +02:00
a69b161178 Merge pull request 'release: v2.9.0' (!37) from release/v2.9.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 8m38s
Reviewed-on: #37
2026-06-25 08:11:37 +00:00
024deadf78 chore(release): prepare v2.9.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m5s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:54 +02:00
599c3c03e7 Merge pull request 'feat(ui): consistent motion polish, predictive back & reduced-motion' (!36) from feat/ui-polish-animations into release/v2.9.0
Reviewed-on: #36
2026-06-25 07:52:32 +00:00
5d48c90408 docs(changelog): note motion polish, predictive back & reduced-motion
All checks were successful
CI / ci (pull_request) Successful in 4m27s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:20:45 +02:00
c6d15d0118 feat(ui): consistent motion polish, predictive back & reduced-motion support
Centralise the app's motion vocabulary in CalendarTransitions.kt and route
every surface through it so animation is consistent app-wide:

- Shared expand/collapse, list-item and fade-through helpers; the event-edit
  expand pattern is now the shared one (no duplicate).
- Settings reminder-override rows and the reminder Custom field now expand/
  collapse instead of bare-fading.
- Search and agenda rows animate (fade/relocate) via animateItem.
- Month/week/day slide and the onboarding gates honour the new helpers.
- View switching (month/week/day/agenda) now fades through instead of
  snapping — lateral navigation per M3, while in-view paging keeps its slide.

Add full predictive-back support:

- enableOnBackInvokedCallback in the manifest.
- New Modifier.predictiveBack(onBack) drives the standard preview transform
  (scale/shift/round) following the back gesture; applied to detail, edit,
  search, settings (+ sub-screens & calendar manager via CollapsingScaffold),
  the calendar editor and import — each keeping its existing back semantics.

Reduced-motion guardrail throughout: rememberReduceMotion() (reads the OS
"remove animations" setting, which Compose ignores by default) collapses
spatial motion to a quick fade and skips the back preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:19:35 +02:00
79e083b4d4 Merge pull request 'feat: configurable default view + widget-aware back navigation' (!35) from feat/view-back-stack into release/v2.9.0
Reviewed-on: #35
2026-06-25 06:25:15 +00:00
119a8afe8e feat(nav): interactive month widget + history-retracing back
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 4m18s
On-device review follow-ups:

- Month widget grid is now tappable. Day numbers open that day and event
  bars open the event's detail, both rooted in the month view so back
  returns to the grid. Previously only the prev/next/today header
  controls responded — the grid cells were never clickable.

- Pill/drawer view switches now build a visit history instead of
  collapsing to the default view. Back retraces the views you moved
  through (a not-yet-visited view is pushed; revisiting one collapses the
  loop back to it), down to the default, then exits. Widget launches
  still reset to their own view context.

Refs #1, #2 (reported by @devinside).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:17:23 +02:00
b537e143f8 feat(nav): default view setting + widget-aware back stack
Replace CalendarHost's single global view slot with a top-level view
back stack rooted at a user-configurable default view. A lateral move
(pill/drawer/widget) replaces the non-home top; a date tap drills the
day view on top; a base-level BackHandler pops one level until only the
home view remains, then the system exits.

Widgets now carry their source view (EXTRA_SOURCE_VIEW) so a launch
roots the stack in that widget's view: backing out of a day/event opened
from the agenda widget returns to Agenda, and from the month widget to
Month, instead of always landing on Week. Reminder taps keep the
separate detail-key channel and leave the base view untouched.

Add a Default view setting (Settings -> Appearance) backing the stack's
home view; defaults to Week so existing users see no change.

Resolves the two Codeberg reports from @devinside:
- #1 [FR] Option to set default view
- #2 [Bug] Widget UX improvement

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:07:00 +02:00
faf2f27eda Merge pull request 'release: v2.8.0' (!34) from release/v2.8.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 30s
Release — F-Droid repo + Gitea release / release (push) Successful in 6m18s
Reviewed-on: #34
2026-06-25 05:18:55 +00:00
3d8e3ca69e chore(release): prepare v2.8.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m5s
Bump versionName to 2.8.0 (versionCode 20800) — the merge of this to main
is what cuts the release. Move the accumulated 2.8.0 work out of
[Unreleased] into a dated CHANGELOG section and regenerate the F-Droid
per-version changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:42:56 +02:00
3b5c0be765 Merge pull request 'feat: full-text event search' (!33) from feat/event-search into release/v2.8.0
Reviewed-on: #33
2026-06-23 09:27:51 +00:00
013efef29e fix(search): clear on reopen and centre messages above the keyboard
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m14s
The search ViewModel is activity-scoped and outlives the overlay, so a reopened
search showed the previous query/results — reset the query when the screen
re-enters (peeking a result keeps it, as the screen stays composed under the
detail). Add imePadding so the idle/empty message re-centres in the area above
the keyboard instead of staying centred on the full page behind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:05:26 +02:00
5457a34282 feat(search): show a recurring result's next occurrence
A recurring master's DTSTART is the series start, which for long-running series
reads as an old date and sorts into the past. For recurring hits (non-empty
RRULE/RDATE), resolve the occurrence nearest to now via the Instances provider —
the soonest upcoming within ~2 years, else the most recent past — and display
and sort by that. Falls back to the series start when no occurrence lies in the
window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:51:28 +02:00
82e93fda4c feat(search): full-text event search from the top bar
A magnifier in each calendar screen's top bar opens a search overlay: type a
query and matching events (title, location or description) appear, nearest-to-
today first; tapping a result opens its detail.

- Data: query the Events table directly with a LIKE selection on title /
  description / location (wildcards escaped), so search is unbounded in time and
  filtered provider-side. New SearchProjection + toSearchResult mapper reuse the
  .ics export's DURATION handling for recurring masters. Hidden calendars are
  filtered out, mirroring instances().
- SearchViewModel debounces the query (250 ms), needs >= 2 chars, and orders
  results upcoming-ascending then past-descending.
- SearchScreen: autofocused inline field in the top bar, GroupedRow results
  reusing the agenda row style, idle/empty states. Hosted as a CalendarHost
  overlay below detail/edit so a tapped result's detail draws on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:42:37 +02:00
871cc34cab Merge pull request 'feat: 'New event' Quick Settings tile' (!32) from feat/qs-tile-new-event into release/v2.8.0
Reviewed-on: #32
2026-06-23 08:18:09 +00:00
5b99da32b0 fix(qs): silence StartActivityAndCollapseDeprecated lint error
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m15s
lintDebug aborts on the deprecated startActivityAndCollapse(Intent) overload in
the pre-34 branch, even though that overload is the only one available below
UpsideDownCake and is reached only there. Suppress the lint issue (and the
compiler deprecation) at the function level since the call is intentional and
version-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:13:33 +02:00
b8a7191fbe fix(nav): bring external new-event/open-date to the front
Some checks failed
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Failing after 3m36s
The create form and Settings/calendar-manager are sibling overlays in one Box,
with Settings drawn above the form. An external 'new event' request (QS tile,
launcher shortcut, widget) set the create state correctly but rendered the form
underneath an open Settings, forcing the user to back out first.

Dismiss the covering overlays (Settings, calendar manager, detail/edit, import)
when handling a Create or OpenDate nav request, so the requested destination is
revealed on top. Fixes the same latent bug for the shortcut and widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:59:44 +02:00
6b0eb48056 Merge pull request 'feat: current-time line in day and week views' (!31) from feat/now-line into release/v2.8.0
Reviewed-on: #31
2026-06-23 07:59:12 +00:00
572a4734ea feat(qs): add a 'New event' Quick Settings tile
A stateless TileService that opens the create-event form on today — the same
action as the launcher 'New event' shortcut and the agenda widget's +. Reuses
MainActivity.openCreateIntent; wrapped in unlockAndRun and using the API 34+
startActivityAndCollapse(PendingIntent) form (deprecated Intent form below 34).
No new permission (BIND_QUICK_SETTINGS_TILE is system-side).

Discoverability: Settings → New event form gains an 'Add Quick Settings tile'
row that fires StatusBarManager.requestAddTileService (API 33+); on older
versions the tile is still addable manually from the system QS editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:52:24 +02:00
7f8a9069c0 feat(views): show a current-time line in day and week views
All checks were successful
CI / ci (pull_request) Successful in 4m21s
A thin primary-coloured indicator (leading dot + line) marks the current
time across today's column in the day and week timelines, positioned on the
same HOUR_HEIGHT scale as the event blocks so it lines up with the grid.

Shared ui/common/NowLine.kt ticks once a minute, re-aligning to each minute
boundary to avoid drift, and only mounts on today's column so a single
coroutine runs. Renders nothing when today isn't in view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:36:40 +02:00
22c4eded96 Merge pull request 'feat: pick a location from the contact picker' (!30) from feat/location-from-contacts into release/v2.8.0
Reviewed-on: #30
2026-06-23 07:31:04 +00:00
9839f9cd38 feat(edit): pick a location from the contact picker
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m19s
Reuse the attendee contact-picker mechanism for the location field: a
Contacts button beside the location input opens the system picker scoped
to postal-address rows, and the chosen contact's formatted address is
dropped into the field (multi-line addresses collapsed to one line).

Same no-permission guarantee as the guest picker — ACTION_PICK grants
temporary read access, so no READ_CONTACTS is required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:22:51 +02:00
c0e27133b4 Merge pull request 'feat: editable event attendees with contact picker' (!29) from feat/attendee-editing into release/v2.8.0
Reviewed-on: #29
2026-06-23 07:12:28 +00:00
a4e0ec3fde feat(edit): add guests from the contact picker
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 9m19s
Close the name-on-manual-add gap. The add-guest row gains a "from
contacts" button that launches the system contact picker (ACTION_PICK on
ContactsContract Email URI); the picked email row is queried for
Email.ADDRESS + DISPLAY_NAME, so a chosen guest gets both email and name
in one tap. No READ_CONTACTS — the result Intent grants temporary read
access to just that row, so Calendula stays no-permission/no-network. The
inline email field remains the quick email-only path. Mark the roadmap
item shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:50:23 +02:00
ac17343747 docs(roadmap): attendee write-side shipped; contact picker is the name path
Mark the attendee-editing write side shipped (b0f34ff) and record the
plan to close the name-on-manual-add gap: a "from contacts" entry via
ACTION_PICK on the contacts Email URI, querying the picked row for
Email.ADDRESS + DISPLAY_NAME — no READ_CONTACTS (the result Intent grants
temporary read access), same no-permission mechanism as the location
address picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:44:51 +02:00
b0f34ff18a feat(edit): make event attendees editable
Attendees were read-only (shown on the detail screen since v0.6) but the
form couldn't write them — the last read-only gap in the event model.
Add a Guests section to the create/edit form:

- Inline grouped list: each guest is a tonal card (avatar, name/email, a
  tappable Required/Optional role chip, remove); the trailing card is an
  inline email field — type an address, press Done, it commits. No dialog,
  matching the app's inline-field input idiom. Manual adds are email-only
  (names come from sync); the InlineTextField gains IME-action support.
- Reminders restyled to the same grouped-list pattern (shared
  GroupedItemCard / AddActionCard), replacing the single-card blob.

Persistence (CalendarDataSource): new guests are written as plain
RELATIONSHIP_ATTENDEE / STATUS_INVITED rows — no fabricated organizer.
On edit, a dirty-checked reconcileAttendees diffs by email: drops removed
guests, inserts new ones, updates only the required/optional flag on kept
rows (preserving response status). Organizer, resources and no-email rows
are never touched. toEditForm carries only editable guests, so attendees
now ride in the edit snapshot and an external guest change trips the
conflict check.

Per the settled invitation decision: Calendula has no INTERNET and never
sends an invitation — it only writes the rows; the backend decides
delivery. The section shows honest, calendar-aware copy ("your account
may email guests when it syncs" on synced calendars, "no one is notified"
on local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:42:37 +02:00
ab8a9e3401 docs(roadmap): settle attendee-invitation behavior (record-only)
Decision (owner, 2026-06-22): attendee editing is record-only on any
writable calendar. Calendula has no INTERNET and never sends an
invitation itself — it only writes Attendees rows. Notification is
decided downstream (local: no one; CalDAV: server iMIP; Google: Google).
Mandatory backend-aware copy makes this honest. No fabricated ORGANIZER;
the optional "send .ics via email app" delegate is deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:05:11 +02:00
92ec718b28 docs(roadmap): sync state and promote attendee editing to Tier 5
Bring the planning docs up to date with reality from the changelog and
recent merges:
- Mark the .ics engine (export + import) shipped in v2.7.0 and local-
  calendar backup done; clear the stale "in progress" tags.
- Note snooze/dismiss merged into release/v2.8.0 alongside Codeberg
  crash reports.
- Record drag-and-drop rescheduling as consciously rejected.
- Promote attendee editing out of the gated bucket into Tier 5 #12
  (read side already shipped in v0.6; only the write side is missing),
  with the sync-adapter invitation caveat to resolve first.
- Refresh STATE.md (was stuck at v2.4) through v2.7.5 and the 2.8.0
  integration branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:00:45 +02:00
4ad1c09f8f feat(detail): show attendee email under the name
The attendee row only used the email as a fallback when the name was
blank, so a guest with both a name and an email never showed the email.
Add it as a supporting line (bodySmall / onSurfaceVariant) beneath the
name, kept off rows whose headline already falls back to the email.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:00:34 +02:00
5e8cb686e9 Merge pull request 'feat(reminders): snooze + dismiss notification actions' (!28) from feat/reminders-snooze into release/v2.8.0
Reviewed-on: #28
2026-06-22 20:19:36 +00:00
62ebd48e3c docs(changelog): note snooze/dismiss reminder actions
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m33s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:13:36 +02:00
c3dbcca989 Merge pull request 'feat(crash): file reports via the public Codeberg tracker' (!27) from feat/crash-report-codeberg into release/v2.8.0
Reviewed-on: #27
2026-06-22 20:09:42 +00:00
37452be3bd feat(crash): file reports via the public Codeberg tracker instead of email
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 3m22s
Now that a public, writable issue tracker exists (the Codeberg mirror at
codeberg.org/jlmakiola/calendula, which the Gitea issue tab links to),
revert the email hand-off (6150ce6) back to the in-app issue-creation flow:
the crash path opens a prefilled issues/new page (with clipboard copy as the
long-report fallback) and the manual "Report a problem" path opens the issue
template chooser.

The email pivot existed only because the personal Gitea has no public issue
creation (reporters hit a login wall). Codeberg lets anyone register and
file, so that reason is gone. Still no INTERNET permission — the user submits
via the browser themselves. URLs point at Codeberg directly so the prefilled
title/body survive (a Gitea external-tracker redirect wouldn't carry query
params).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:03:10 +02:00
006cce96a9 feat(crash): hand off reports by email instead of the Gitea issue tracker
Filing a Gitea issue requires an account on the instance, so anonymous
reporters hit a login wall — Gitea has no anonymous issue creation. Switch
both the crash-report and manual problem-report paths to compose a
pre-addressed email via ACTION_SENDTO (mailto:), which needs no account and
preserves the existing no-INTERNET, user-sends-it-themselves model. The full
report rides in EXTRA_TEXT, so the old URL-length cap and clipboard-paste
fallback are gone (clipboard copy stays as a safety net).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:03:10 +02:00
88d770bebe Merge pull request 'ci(fdroid): guard all three reproducible-build invariants' (!26) from chore/harden-repro-guard into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #26
2026-06-22 19:58:54 +00:00
b6bee55fc6 ci(fdroid): guard all three reproducible-build invariants
All checks were successful
CI / ci (pull_request) Successful in 2m19s
The reproducible-release CI guard only asserted `vcsInfo { include = false }`.
F-Droid review surfaced two more invariants that, if they regress, silently
stall official publishing (fails safe — stuck on an old version):

- no foojay toolchain resolver in any Gradle script (the offline source
  scanner rejects org.gradle.toolchains.foojay-resolver — it can fetch a JDK
  over the network), and
- `dependenciesInfo { includeInApk = false }` (else AGP embeds a "Dependency
  metadata" block, id 0x504b4453, in the APK Signing Block, which the binary
  scanner rejects as an extra signing block).

The script now checks all three, accumulates failures (reports every broken
invariant in one run, not just the first), and exits non-zero if any fails.
Verified positive + one negative per invariant + all-three-broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:27:58 +02:00
7ab277b434 feat(reminders): snooze + dismiss notification actions
Add "Snooze" and "Dismiss" action buttons to reminder notifications.

The app is otherwise pure provider-broadcast (the Etar model): the calendar
provider fires EVENT_REMINDER and we post the notification, then mark the
CalendarAlerts row fired. A snoozed reminder has no provider backing — its
row is already fired — so snooze self-schedules an exact alarm to re-show
the same notification, while primary delivery is left unchanged.

- ReminderActionReceiver (not exported): SNOOZE cancels + schedules a
  re-show, DISMISS cancels, SHOW (the alarm) re-posts so it can be snoozed
  or dismissed again.
- ReminderSnoozeScheduler: setExactAndAllowWhileIdle, with an inexact
  allow-while-idle fallback if exact alarms are revoked (API 31-32).
- ReminderNotifier: two actions + cancel().
- snoozeMinutes pref (default 10) in Settings -> Notifications, OptionPicker
  presets 5/10/15/30/60.
- Manifest: USE_EXACT_ALARM + SCHEDULE_EXACT_ALARM (maxSdk 32) + receiver.
- New ic_notification_snooze/_dismiss drawables, duration plurals, en+de
  strings, snooze-pref tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:21:01 +02:00
d0b6823385 Merge pull request 'build(fdroid): drop AGP dependency-metadata block; release v2.7.5' (#25) from release/v2.7.5 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 4m58s
Renovate / renovate (push) Successful in 1m6s
Reviewed-on: #25
2026-06-21 19:00:43 +00:00
5c80f99179 build(fdroid): drop AGP dependency-metadata block; release v2.7.5
All checks were successful
CI / ci (pull_request) Successful in 5m57s
F-Droid's binary scanner (the fdroiddata `check apk` job) rejects any
"extra signing block". Our release APK carried AGP's dependency-metadata
block (id 0x504b4453, ~8 KB) in the APK Signing Block. Disable it with
`dependenciesInfo { includeInApk = false; includeInBundle = false }`.

The block lives in the signing block, not the zip entries, so removing it
changes no build output — v2.7.5 is functionally identical to 2.7.4 and
still reproduces byte-for-byte. Verified locally: a releaseTest build now
carries only the v2 signature + verity padding (no 0x504b4453 block).

This was the last F-Droid blocker: v2.7.5 now clears vcsInfo (since 2.7.3),
foojay (since 2.7.4) and the dependency-metadata block. Bumps versionName
to 2.7.5 and retargets the official recipe draft at v2.7.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:48:59 +02:00
0875e69c2d Merge pull request 'build(fdroid): drop unused foojay toolchain resolver; release v2.7.4' (#24) from release/v2.7.4 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) Successful in 8m15s
Reviewed-on: #24
2026-06-21 18:23:46 +00:00
ac572da40a build(fdroid): drop unused foojay toolchain resolver; release v2.7.4
All checks were successful
CI / ci (pull_request) Successful in 8m50s
F-Droid's official build scanner rejects the foojay-resolver-convention
plugin because it can fetch a JDK over the network during the offline,
reproducible build. The plugin was inert here (no toolchain block ever
invoked it), so removing it leaves the app functionally identical to
2.7.3 and still reproducible byte-for-byte from source.

Bumps versionName to 2.7.4 (the release trigger) and retargets the
official fdroiddata recipe draft at v2.7.4, plus two recipe corrections
the fdroiddata CI caught on the v2.7.3 submission (MR !40967):
- Categories: 'Time' (retired in the current taxonomy) -> 'Calendar & Agenda'
- AutoUpdateMode: 'Version v%v' (invalid per schema) -> 'Version'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:09:31 +02:00
f27811d215 Merge pull request 'chore(fdroid): finalize recipe at v2.7.3 + reproducible-release CI guard' (#23) from chore/fdroid-recipe-2730 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #23
2026-06-21 12:59:56 +00:00
36e2199dff chore(fdroid): finalize recipe at v2.7.3 + reproducible-release CI guard
All checks were successful
CI / ci (pull_request) Successful in 8m50s
v2.7.3 is the first release built with `vcsInfo { include = false }` and is
verified reproducible: a clean from-source build matched the published
calendula_v2.7.3.apk byte-for-byte across all 1382 zip entries (only the
signature block differs, which F-Droid copies).

- Point the fdroiddata recipe's Builds entry + CurrentVersion at v2.7.3
  (was the 2.7.2 placeholder, which lacks the fix and would fail verification).
- UpdateCheckMode: Tags ^v[0-9.]+$ — after this one-time submission F-Droid
  auto-tracks new release tags and adds build entries itself; no manual recipe
  edits per release.
- Add scripts/check_reproducible_release.sh + an always-on CI step asserting the
  release build keeps VCS-info disabled, so reproducibility can't silently
  regress and quietly stop official-repo publishing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:28:01 +02:00
eb51175ceb Merge pull request 'fix(widget): widgets stuck on loading spinner in release build (#18)' (#22) from fix/widget-release into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 7m8s
Reviewed-on: #22
2026-06-21 12:15:44 +00:00
bc59200f77 fix(widget): keep WorkManager InputMerger so widgets render under R8 (#18)
All checks were successful
CI / ci (pull_request) Successful in 8m45s
Home-screen widgets were stuck on the Glance loading layout (a blank spinner)
in the minified release build — but worked in debug. Root cause: Glance renders
every widget through a WorkManager worker (androidx.glance.session.SessionWorker),
and WorkManager instantiates the InputMerger reflectively from the fully-qualified
class name persisted in the WorkSpec (Class.newInstance, no-arg ctor). Under R8
full mode (AGP 9 default) the unused no-arg constructor of
androidx.work.OverwritingInputMerger was stripped, so WorkManager threw
"OverwritingInputMerger has no zero argument constructor", the SessionWorker
never ran, and provideContent never executed — leaving the widget on its
initial loading layout forever.

Same R8-reflection family as the v2.7.0 Room keep-rule fix. Keep the name +
constructor of every androidx.work.InputMerger.

Verified on-device with the releaseTest build (R8-minified): the agenda widget,
which showed only a spinner before, now renders its content; the WM-InputMerger
InstantiationException is gone. Closes #18. Cuts v2.7.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:06:34 +02:00
ed6b0fa89f Merge pull request 'chore(fdroid): single-source fastlane metadata + reproducible-build prep' (#21) from chore/release-verification-process 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: #21
2026-06-21 11:38:20 +00:00
c503199e06 ci: make change-scope classification robust to divergent PR branches
All checks were successful
CI / ci (pull_request) Successful in 4m24s
The scope step shallow-fetched the base (`--depth=1`) and ran
`git diff origin/<base>...HEAD`. The three-dot diff needs the merge-base,
which a depth-1 tip doesn't contain once a branch has forked a few commits
back — git aborts with "no merge base" (exit 128) and the whole CI job
fails before lint/test/build ever run.

Fetch the base fully, resolve the merge-base explicitly, and diff from it.
If no common ancestor is found, default to code=true so the build still
runs rather than being wrongly skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:32:30 +02:00
f3f155b5ff chore(fdroid): single-source fastlane metadata + reproducible-build prep
Some checks failed
CI / ci (pull_request) Failing after 8s
Make fastlane/metadata/android/ the single source of truth for store
listing metadata, consumed directly by the official F-Droid repo and
transformed into the self-hosted repo's localized layout at release time.

- Move fdroid-metadata/<appid>/<locale>/ -> fastlane/metadata/android/<locale>/
  (git-tracked renames: summary->short_description, description->full_description,
  + title.txt, images/icon.png, images/phoneScreenshots/); keep the app-level
  .yml control file for the self-hosted `fdroid update`.
- Add scripts/fastlane_to_fdroid_localized.sh (fastlane -> F-Droid localized,
  incl. changelogs) and scripts/sync_changelog_to_fastlane.sh (CHANGELOG.md ->
  fastlane changelog); verified byte-identical to the previous metadata.
- release.yaml: build self-hosted metadata from the fastlane tree and sync the
  per-version changelog before the transform (one changelog source for both
  channels).
- Disable AGP VCS-info embedding on release builds (vcsInfo { include = false })
  so builds reproduce byte-for-byte vs the distributed APK — the only file that
  otherwise differed (META-INF/version-control-info.textproto). Effective from
  the next release.
- Add docs/fdroid-official/ (draft fdroiddata recipe: reproducible build +
  AllowedAPKSigningKeys + Binaries + notes).
- Repoint README screenshots/icon, update docs/README + RELEASING, and skip the
  Android build on fastlane-only changes (ci.yaml).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:17:21 +02:00
294524a943 Merge pull request 'release: v2.7.1 — privacy-respecting crash reporting' (#9) from feat/crash-report into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 5m39s
Reviewed-on: #9
2026-06-21 11:13:25 +00:00
ef6f1891b3 Merge remote-tracking branch 'origin/main' into feat/crash-report
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m35s
# Conflicts:
#	CHANGELOG.md
#	app/build.gradle.kts
2026-06-21 13:04:18 +02:00
9fb3c780f1 Merge pull request 'build,docs: on-device release verification gate (releaseTest build)' (#20) from chore/release-verification-process into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #20
2026-06-21 11:01:29 +00:00
8a80478555 docs(architecture): describe merge-driven release flow
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m14s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:33:55 +02:00
df203c0b6b ci: release on merge-to-main; consolidate pipeline triggers
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m55s
Reworks the pipeline so a change is built once and a release is driven by the
merge, not a manual tag push.

- ci.yaml now runs on pull_request (one gate per PR) instead of every branch
  push, so there's no CI-on-push + CI-on-merge double run. A single `ci` job
  with a docs-only fast-path keeps the required "CI" check always reporting
  (docs/metadata-only PRs skip the Android build but still go green).
- release.yaml triggers on push to main. A cheap `detect` job reads versionName
  from build.gradle; only when no tag for it exists does the `release` job run:
  tests on the merged commit, build + sign, publish to F-Droid, then create the
  vX.Y.Z tag + Gitea release via the API (target_commitish = the merged sha).
  The tag is now an OUTPUT of a successful release, not its trigger — a failure
  before publish leaves no tag, so re-running safely retries. No more separate
  tag-triggered run or duplicate ci job.
- The committed versionName/versionCode are now the source of truth (pipeline
  pins versionCode from versionName); updated the build.gradle comment.
- translations.yaml switched to pull_request (same path filter).
- docs/RELEASING.md: release-by-merge flow, no manual git tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:32:56 +02:00
0c95479051 build,docs: add on-device release verification gate
Adds a mandatory pre-tag step to the release process: build the R8-shrunk
release candidate and smoke-test it on a real device, including a first-run /
permission-not-granted state. The v2.7.0 launch crash (calendar observer
registered before the permission gate) reached users because it only manifests
in the minified release build on a device without the permission already
granted — the debug build and an already-permissioned phone both hid it.

- New `releaseTest` build type: same R8 shrinking + obfuscation as `release`,
  but debug-signed with a `.releasetest` applicationId suffix so it installs
  alongside the production and debug apps. Never published; CI only ever builds
  the real `release` variant from the tag.
- scripts/verify-release.sh: builds + installs `releaseTest` and resets it to a
  first-run state, with an on-device checklist.
- docs/RELEASING.md: formalize the release/vX.Y.Z branch flow and the on-device
  verification gate before tagging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:27:30 +02:00
221313178f release: renumber crash-report release to v2.7.2
All checks were successful
CI / ci (push) Successful in 9m6s
v2.7.1 is taken by the launch-crash fix (calendar observer registered before
the permission gate). Bump this held crash-reporting release to 2.7.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:01:07 +02:00
297a2350e3 Merge pull request 'fix(calendar): don't register calendar observer before permission granted' (#19) from fix/calendar-observer-permission-crash into main
All checks were successful
CI / ci (push) Successful in 4m8s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m29s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 5m43s
Reviewed-on: #19
2026-06-21 09:59:32 +00:00
997ee44792 fix(calendar): don't register calendar observer before permission granted
All checks were successful
CI / ci (push) Successful in 4m15s
The repository registers a ContentObserver on the calendar provider eagerly
in its init block, and an activity-scoped SettingsViewModel (which drives the
theme) injects that repository — so the @Singleton is constructed at launch,
above RootScreen's permission gate. On newer Android, registering an observer
on a provider you lack permission for throws SecurityException instead of
silently no-op'ing, so the app crashed instantly on every launch whenever
calendar access wasn't granted (fresh install or revoked permission), before
the permission screen could ever appear.

Guard the registration behind a calendar-permission check and re-attach the
observer lazily on the first calendars()/instances() read, which runs once the
gate opens and screens subscribe. Access to the observer collections is now
synchronized since registration can happen on the main thread (repo init) or
the IO dispatcher (query re-attach).

Verified on-device: permission-denied launch shows the permission screen
instead of crashing; granting it proceeds to the calendar with live updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:52:28 +02:00
81baadfaf3 Merge pull request 'fix(renovate): run renovate image directly instead of docker-wrapping action' (#11) from fix/renovate-action-pin into main
All checks were successful
CI / ci (push) Successful in 11m18s
2026-06-19 09:16:23 +00:00
35022267dc fix(renovate): run renovate image directly instead of docker-wrapping action
All checks were successful
CI / ci (push) Successful in 1m52s
renovatebot/github-action is a Node wrapper that shells out to
`docker run ghcr.io/renovatebot/renovate`, requiring a Docker CLI + socket
inside the job. The Gitea runner executes the job in a plain node:22 container
with neither, so it died on "Unable to locate executable file: docker".

Run the renovate image as the job container and invoke `renovate` directly —
drops the docker-in-docker requirement. Full tag pinned; Renovate's
github-actions manager keeps container.image bumped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:08:08 +02:00
588e024036 Merge pull request 'fix(renovate): pin action to v46.1.15' (#10) from fix/renovate-action-pin into main
All checks were successful
CI / ci (push) Successful in 1m45s
2026-06-18 20:34:59 +00:00
eeef089e4a fix(renovate): pin action to a real tag (v46.1.15)
All checks were successful
CI / ci (push) Successful in 1m31s
renovatebot/github-action ships only full semver tags; @v40 was an
invalid ref and the dispatched run failed to resolve it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:33:15 +02:00
9023899ddb Merge pull request 'ci(renovate): self-hosted Renovate config + weekly workflow' (#8) from feat/renovate into main
All checks were successful
CI / ci (push) Successful in 8m43s
2026-06-18 15:17:47 +00:00
5ab3344f8c release: prepare v2.7.1 — privacy-respecting crash reporting
All checks were successful
CI / ci (push) Successful in 5m16s
Bump committed versionCode/versionName to 2.7.1 (20701) and move the
crash-reporting entry under a 2.7.1 CHANGELOG heading. The tag remains the
source of truth; CI derives the published version from it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:14:10 +02:00
2431abe912 fix(crash): keep event/calendar content out of exception messages
Audit of our own throw sites, since exception messages land verbatim in
the stack trace a crash report carries. Redacts the three that could hold
user content; the rest only carry numeric ids/timestamps (metadata, kept
for debugging):

- create-local-calendar: drop the user-typed calendar name.
- toContentValues unsupported-type: log the value's type, never the value
  (a cell can be an event title/description/location).
- ics export open-failure: log only the Uri scheme, not the full Uri
  (which can embed the user's chosen filename).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:13:26 +02:00
2f153fef56 ci(renovate): self-hosted Renovate config + weekly workflow
All checks were successful
CI / ci (push) Successful in 1m31s
renovate.json5 (config:recommended + semantic commits, no automerge,
dependency dashboard; material3 stays on its 1.5-alpha pin in an
isolated PR; test deps grouped; github-actions manager watches
.gitea/workflows). Cadence owned by .gitea/workflows/renovate.yml
(Mondays 05:00 UTC + manual dispatch), self-hosted via
renovatebot/github-action, scoped to makiolaj/calendula.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:07:46 +02:00
701077f25b feat(crash): privacy-respecting crash reporting via Gitea issue
Capture uncaught exceptions on-device and let the user submit them, by
hand, as a Gitea issue — no network access, no auto-upload (the app holds
no INTERNET permission). Closes prod-readiness item 10; the issue
templates also close item 7.

- CrashReporter: uncaught-exception handler installed first in
  CalendulaApp.onCreate so startup crashes are caught too. Persists an
  allowlist-only report (app/Android/device version, locale, time, stack
  trace — nothing else) to filesDir/crash, then chains to the previous
  handler so the process still dies normally. Crash-loop detection +
  markHealthy reset.
- buildCrashReport is pure/testable; CrashReportBuilderTest asserts the
  header is exactly the allowlisted lines (guards against PII creep).
- Surfacing: next-launch dialog showing the full report verbatim (the
  privacy backstop) with a dismissed-marker so it doesn't nag; a Settings
  "Report a problem" row; and a minimal standalone CrashReportActivity
  that MainActivity routes to on a startup crash-loop, kept clear of the
  Hilt graph / DataStore theme.
- submitCrashReport copies the report to the clipboard and opens the
  prefilled Gitea issues/new URL (long traces fall back to paste).
- .gitea/ISSUE_TEMPLATE: crash_report, bug_report, feature_request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:54:35 +02:00
290a905f8b Merge pull request 'release: v2.7.0 — ICS export & import' (#7) from release/v2.7.0 into main
All checks were successful
Translations / check (push) Successful in 6s
CI / ci (push) Successful in 9m40s
2026-06-18 14:26:53 +00:00
d20d446cbe release: cut v2.7.0 — ICS export & import (.ics share, backup, open/receive)
All checks were successful
CI / ci (push) Successful in 5m48s
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m40s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 5m44s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:24:35 +02:00
6e14d5964b fix(release): keep Room DB impls so R8 doesn't crash startup
The minified release build crashed on every launch before any UI:

  Unable to get provider androidx.startup.InitializationProvider:
    Failed to create an instance of androidx.work.impl.WorkDatabase

The home-screen widgets use Glance, which pulls in WorkManager and its
transitive Room database (room-runtime 2.2.5). Room 2.2.5's bundled keep
rule is `-keep class * extends androidx.room.RoomDatabase` — it keeps the
class but not its constructor. Under R8 full mode (AGP 9) the generated
WorkDatabase_Impl was reduced to a non-instantiable class, so Room's
reflective newInstance() threw InstantiationException at startup.

Add `-keep class * extends androidx.room.RoomDatabase { *; }` so the
generated *_Impl classes keep their constructors. Verified against the
rebuilt release APK: WorkDatabase_Impl is now PUBLIC FINAL with its
<init> present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:15:08 +02:00
3dfc96718c feat(ics): import UI — open/receive .ics, 1-vs-many routing
Completes v2.7 Branch 2. Wires the import core into the app:

- Manifest ACTION_VIEW/SEND for text/calendar; MainActivity parses the
  incoming Uri (content/file only, so calendula:// deep-links don't match)
  and routes it through RootScreen → CalendarHost like the other one-shot
  intents.
- ImportViewModel reads + parses the file and routes by count: one event →
  the prefilled create form for review (EventEditViewModel.openImported,
  which freezes the reminder default so the file's reminders win); many →
  ImportScreen with a writable-calendar picker, then a bulk import (UID
  dedup) and a result summary.
- ImportScreen also surfaces parser warnings (skipped recurrence overrides,
  ignored attendees, unknown-timezone fallback). Strings EN+DE.

Package is ui.imports (not ui.import — Java keyword). lint + test +
assembleDebug green. No v2.7 tag until on-device review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:20:29 +02:00
e1c2e9f2e5 feat(ics): import core — parser, dedup-aware bulk import, form prefill
v2.7 Branch 2 (core, no UI yet). The read side of the .ics engine:

- domain/ics: IcsParser (inverse of IcsWriter) — unfold/unescape/param
  parsing, VALUE=DATE / UTC-Z / TZID date handling resolved against the OS
  tz db, VEVENT walk → ParsedIcsEvent + typed warnings. Liberal-in/
  strict-out: a malformed VEVENT is skipped, RECURRENCE-ID overrides /
  attendees / unresolved TZIDs are reported, not silently dropped.
- Promoted parseRfc2445DurationMillis into domain/ics (shared by writer-
  side mapper and parser); IcsDuration + test.
- Datasource existingUids()/insertImportedEvent(); repository
  importEvents() with UID dedup (skip known UIDs → idempotent restore) →
  IcsImportSummary. IcsImporter reads a Uri's text.
- ParsedIcsEvent.toEventForm() for the single-event "open into the create
  form" path.

Parser round-trips against IcsWriter; dedup + form-adapter unit-tested.
Intent filter, routing and import UI land in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:59:32 +02:00
90b219bdad fix(views): stop single-day all-day events leaking into the next day
All-day events live at UTC midnights with an exclusive end, but coversDay
sliced each day in the device timezone. East of UTC the exclusive end
landed a few hours into the next local day, so a one-day all-day event
(e.g. a birthday) rendered on two days in the day/week/month views — while
the detail and edit screens, which work in UTC, showed it correctly.

Compare all-day coverage in UTC and step the exclusive end back to the
last covered day, mirroring the detail/edit views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:48:34 +02:00
233a9b03a3 Merge feat/ics-export into release/v2.7.0
v2.7 Branch 1 of 2: .ics export — single-event share + whole-calendar backup of local calendars. Import (feat/ics-import) lands next in the same release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:45:59 +02:00
0b683d374f feat(ics): export — share single event + back up local calendars as .ics
Branch 1 of 2 for v2.7 (the .ics topic). Adds the write side of a
hand-rolled RFC 5545 engine (zero deps, stays on kotlinx-datetime):

- domain/ics: IcsText (escape + 75-octet folding), IcsEvent model,
  IcsWriter.writeCalendar. Timezone rule: all-day VALUE=DATE, one-off
  timed UTC Z, recurring timed TZID-labelled from EVENT_TIMEZONE (no
  VTIMEZONE — import resolves TZID against the OS tz db).
- Single-event share from the detail screen (FileProvider + ACTION_SEND).
- Whole-calendar backup of the writable local calendars to a SAF file
  (Settings -> Calendars -> Export as .ics), one combined VCALENDAR.
- insertEvent now writes Events.UID_2445; legacy rows fall back to a
  stable synthesised UID at export time so a later restore won't dupe.
- EXDATE / RECURRENCE-ID overrides are deliberately skipped this pass
  (documented v1 limit; import will skip them too).

Engine + mapper unit-tested. Import (Branch 2, feat/ics-import) ships in
the same v2.7 release; no tag until both land + on-device review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:27:53 +02:00
64d0a89b28 release: cut v2.6.0 — working in-app language picker + system per-app language
All checks were successful
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m43s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
CI / ci (push) Successful in 9m33s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 7m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:38:58 +02:00
7285e274df Merge pull request 'feat(i18n): data-driven language picker + Weblate translation guard' (#5) from feat/translations into main
All checks were successful
Translations / check (push) Successful in 4s
CI / ci (push) Successful in 1m43s
2026-06-18 08:41:46 +00:00
788ca3906e Merge remote-tracking branch 'origin/main' into worktree-feat+translations
All checks were successful
Translations / check (push) Successful in 4s
CI / ci (push) Successful in 5m11s
2026-06-18 10:29:00 +02:00
bab6fd175a fix(i18n): make the language picker actually apply on device
The in-app language picker silently did nothing: AppCompatDelegate.set
ApplicationLocales only syncs to the system from an AppCompatActivity, but
MainActivity was a plain ComponentActivity (with a platform theme). Switch
MainActivity to AppCompatActivity and base Theme.Calendula on
Theme.AppCompat.DayNight.NoActionBar.

Changing the locale recreates the activity; set android:windowBackground to a
DayNight colour matching the Compose background (light #FBFCFE / dark #101316)
so the recreation no longer flashes a contrasting backdrop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:28:13 +02:00
3d5cc55ef1 Merge pull request 'feat(reminders): configurable all-day reminder fire time' (#6) from feat/default-reminders into main
All checks were successful
CI / ci (push) Successful in 10m19s
2026-06-18 07:58:47 +00:00
111b3782b0 feat(reminders): configurable all-day reminder fire time
All checks were successful
CI / ci (push) Successful in 3m37s
All-day events live at UTC midnight, so a raw "1 day before" reminder
fires at an off hour (02:00 local in CEST) rather than the morning. Add a
global "all-day reminder time" setting (default 09:00) and encode it into
the provider MINUTES offset so the reminder lands at the chosen wall-clock
time the day before instead.

- AllDayReminderEncoding: pure to/from provider-minutes helpers, keeping
  the form/UI/diff in whole-day "semantic" minutes and converting only at
  the Reminders read/write boundary (insertEvent, reconcileReminders,
  EventDetailMapper). Covers DST, negative offsets, and pre-existing rows.
- SettingsPrefs.allDayReminderTimeMinutes (default 540) threaded from the
  repository into the data-source write paths.
- Settings: a time-picker row, plus a shared TimePickerAlert lifted from
  the event editor.
- Fix the time picker's 12/24-hour detection: honour an explicit system
  override, else fall back to the device locale rather than the app's
  per-app language, so it matches the rest of the device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:54:41 +02:00
cf380b6eab ci(i18n): translation parity guard + allow partial translations
Some checks failed
CI / ci (push) Failing after 17m45s
Translations / check (push) Successful in 7s
Add scripts/check_translations.py and a lightweight Translations workflow
that runs it (no Android SDK needed) so Weblate PRs get fast feedback. The
script fails on stale keys (present in a translation but not the base) and on
translating translatable="false" entries; missing keys are reported as
coverage only.

Downgrade lint's MissingTranslation to informational: partial community
translations are expected and fall back to the English base at runtime.
Stale/extra keys (ExtraTranslation) remain fatal in lintDebug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:43:06 +02:00
9177a926df feat(i18n): data-driven language picker + locale config
Make the supported-language list a single source of truth so community
translations show up with no code change: add res/xml/locales_config.xml
(en, de) and reference it via android:localeConfig, which also surfaces the
per-app language entry in Android 13+ system settings.

Rewrite AppLanguage to parse locales_config.xml for the supported BCP-47
tags and expose currentTag/apply/displayName (autonyms), dropping the
hardcoded LanguagePref enum; the Settings picker is now built from that list.
Remove the now-unused settings_language_german/english strings.

Adding a language is now: drop in values-<tag>/strings.xml and add one
<locale> line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:31:54 +02:00
5e6defd4c7 release: cut v2.5.0 — home-screen widgets, agenda, jump-to-date, quick actions
All checks were successful
CI / ci (push) Successful in 12m38s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m19s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 10m1s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s
Bundles the unreleased Tier 2/3 work into one release:

- Home-screen widgets (Glance): an "Upcoming" agenda widget and a month-grid
  widget, both reusing the in-app grouping/layout (groupAgendaDays,
  layoutMonthWeeks) via a Hilt WidgetEntryPoint, honouring hidden-calendar
  filters and refreshing on PROVIDER_CHANGED / date rollover.
- App shortcut: launcher long-press "New event", routed through the shared
  WidgetNavRequest.Create channel into the create-event form.
- Agenda view and jump-to-date (already merged via #3/#4) are documented here
  as part of the shipped version.

Bumps versionCode 20500 / versionName 2.5.0, moves the CHANGELOG Unreleased
section under [2.5.0], updates ROADMAP/STATE, and adds EN+DE strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:33:58 +02:00
6e7ae3e60d Merge pull request 'feat(agenda): Agenda view — upcoming events grouped by day' (#4) from feat/agenda-view into main
All checks were successful
CI / ci (push) Successful in 6m45s
2026-06-17 07:45:04 +00:00
b0b30eef91 feat(agenda): add Agenda view — upcoming events grouped by day
All checks were successful
CI / ci (push) Successful in 6m24s
The fourth top-level view, alongside Month/Week/Day. A forward-looking
LazyColumn of upcoming events grouped under sticky day headers, reusing
the v2.3 grouped-list language (GroupedRow cards, color-rail leading).

- AgendaViewModel loads a 60-day forward window from the anchor day
  (today by default; goToToday/goToDate drive the FAB + drawer jump),
  groups instances by local day (ongoing/multi-day clamped to the
  anchor), sorts all-day-first then by start.
- AgendaScreen: same drawer + scaffold + view-switcher + FAB shell as
  Day; sticky "Today · …"/"Tomorrow · …" headers, event rows with
  time·location, plus empty/failure/loading states.
- Wired into CalendarView (ViewAgenda icon), IMPLEMENTED_VIEWS, and
  CalendarHost; strings added (EN + DE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:41:36 +02:00
8b25c9be39 Merge pull request 'feat(nav): jump-to-date action in the navigation drawer' (#3) from feat/jump-to-date into main
All checks were successful
CI / ci (push) Successful in 6m31s
2026-06-17 07:25:46 +00:00
2943f3945d feat(nav): jump-to-date action in the navigation drawer
All checks were successful
CI / ci (push) Successful in 6m17s
Add a "Jump to date" row to the drawer (under the View switcher) that
opens an M3 date picker and navigates the active view to the chosen day,
sliding in from the correct side. Wired across Month/Week/Day, each
seeding the picker with its visible anchor (day / week-start / 1st-of-month).

Extract the form's private date-picker into a shared
ui/common/CalendarDatePickerDialog so the event form and the drawer share
one picker; add goToDate() to the Month and Week view models.

Reprioritises the roadmap: jump-to-date is now next; duplicate-event drops
to the bottom as low-importance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:24:49 +02:00
b62f097392 release: cut v2.4.0 — per-event colors
All checks were successful
CI / ci (push) Successful in 9m20s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m3s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 9m22s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 7s
Optional per-event color in the event form. The read/render path already
resolved EVENT_COLOR with a calendar fallback; this adds the write side and
the picker.

- Palette-backed calendars (Google, some CalDAV) pick from the account's
  Colors (TYPE_EVENT) and write EVENT_COLOR_KEY, so the color round-trips
  through sync; local calendars write a raw EVENT_COLOR from the shared
  CALENDAR_COLOR_PALETTE. Never writes a raw color to a palette calendar.
- Swatch row + palette extracted to ui/common/ColorSwatchRow.kt (shared with
  the calendar editor). Switching calendars resets the choice (keys are
  account-scoped); a "Reset" action returns to the calendar color.
- New "Allow colors on unsupported calendars" setting (off by default)
  extends the raw path to no-palette synced calendars, with an honest
  "may not survive sync" warning on the picker and in Settings.
- Color flows through insert / dirty-checked update / occurrence-exception;
  mapper, form, and repository tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:55:16 +02:00
205 changed files with 15474 additions and 1164 deletions

View File

@@ -0,0 +1,23 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -0,0 +1,27 @@
---
name: Crash report
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
- crash
- priority:high
---
<!--
Thanks for reporting a crash in Calendula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
-->
### What happened
### Crash report
```
(paste the crash report here)
```

View File

@@ -0,0 +1,16 @@
---
name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- feat
---
### What would you like Calendula to do?
### Why — what problem does it solve?
### Anything else
<!-- mockups, examples from other apps, alternatives you considered -->

View File

@@ -0,0 +1,19 @@
---
name: Question
about: Ask how something works or get help using Calendula
title: ""
labels:
- question
---
### Your question
### What you've tried
<!-- so far, if anything -->
### Context
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -1,18 +1,23 @@
name: CI
# One gate per pull request. Branch pushes no longer trigger CI on their own,
# so a change is built once on its PR (covering feature -> release/* and
# release/* -> main) instead of once per push and again on the merge to main.
# The merge itself is handled by release.yaml, which only does heavy work when
# the merge actually cuts a release.
on:
push:
branches:
- '**'
tags-ignore:
- '**'
pull_request:
# Cancel superseded runs on the same branch.
# Cancel superseded runs for the same PR.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Single job named `ci` so the required "CI" status check is always reported,
# even for docs-only PRs: those just skip the Android build and the job still
# succeeds (fast green check) instead of being filtered out and leaving the
# required check pending forever.
ci:
runs-on: docker
env:
@@ -21,14 +26,53 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
# Cheap, always-on guard: the release build must stay reproducible for the
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
# change scope so a regression can't slip through on a "docs-only" PR.
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
run: |
set -e
BASE="${{ github.base_ref }}"
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
git fetch --no-tags origin "$BASE"
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
if [ -z "$MB" ]; then
# No common ancestor available — don't risk skipping the build.
echo "No merge base with origin/$BASE — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
fi
- name: Setup Java
if: steps.scope.outputs.code == 'true'
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
@@ -36,12 +80,14 @@ jobs:
packages: ''
- name: Setup Android SDK cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
if: steps.scope.outputs.code == 'true'
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
@@ -50,6 +96,7 @@ jobs:
"build-tools;36.0.0"
- name: Setup Gradle cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4
with:
path: |
@@ -60,21 +107,25 @@ jobs:
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
if: steps.scope.outputs.code == 'true'
run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug
- name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug
- name: Trivy filesystem scan
if: github.ref == 'refs/heads/main'
if: steps.scope.outputs.code == 'true'
run: |
set -e
SUDO=""

View File

@@ -1,75 +1,84 @@
name: Release — F-Droid repo + Gitea release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
# merges (no version bump) fall through `detect` and do nothing.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
# recovery.
on:
push:
tags:
- '*'
branches: [main]
workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
jobs:
ci:
# Cheap gate: resolve the version from the committed build.gradle and decide
# whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main.
detect:
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
- name: Resolve version and whether it is a new release
id: v
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
if [ "$STATUS" = "200" ]; then
echo "Tag v$VERSION already exists — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
else
echo "No tag for v$VERSION yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# Lint already enforced on every push to main via ci.yaml.
# Release sanity only re-runs tests + a debug build to catch
# any tag-resolved drift (e.g. version code substitution issues).
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug
build-and-deploy:
needs: ci
# Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and
# re-uploads the existing index (recovery path).
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -121,31 +130,26 @@ jobs:
$SUDO apk add --no-cache jq
fi
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
# not a tag) these are skipped: the job then just re-signs the existing
# index with the configured repo key and re-uploads — used for key
# rotation / repo recovery without publishing a new APK.
- name: Set version from git tag
if: startsWith(github.ref, 'refs/tags/')
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: |
set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${RAW_TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
# Export for later steps (F-Droid changelog, mapping asset name).
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
# Test the exact commit being shipped (only on a real release).
- name: Unit tests
if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest
- name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/')
if: env.IS_RELEASE == 'true'
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -160,11 +164,8 @@ jobs:
storeFile=upload-keystore.jks
EOF
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release APK
if: startsWith(github.ref, 'refs/tags/')
if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools
@@ -202,8 +203,7 @@ jobs:
set -euo pipefail
# Fail loudly if the repo key is not configured. NEVER auto-generate
# one: a fresh key changes the repo fingerprint and breaks every
# user's pinned repo. (Replaces the old `fdroid update --create-key`
# path, which silently rotated the key on a wiped server.)
# user's pinned repo.
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
echo "Refusing to continue — will not auto-generate a new repo key." >&2
@@ -216,42 +216,33 @@ jobs:
mkdir -p fdroid/repo/icons
- name: Copy new APK to repo
if: startsWith(github.ref, 'refs/tags/')
if: env.IS_RELEASE == 'true'
run: |
set -e
mkdir -p fdroid/repo
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_${SAFE_REF_NAME}.apk"
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
- name: Copy metadata to F-Droid repo
# Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
# the commit having happened). The transform below then carries it across.
- name: Ensure this version's changelog is in the fastlane tree
if: env.IS_RELEASE == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
- name: Build F-Droid metadata from fastlane (single source of truth)
run: |
mkdir -p fdroid/metadata
cp -r fdroid-metadata/* fdroid/metadata/
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
# section written to changelogs/<versionCode>.txt (same extraction as the
# Gitea release notes). en-US only — F-Droid falls back to it for locales
# without their own changelog. fdroid update bakes this into the index.
- name: Generate F-Droid changelog for this version
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > /tmp/changelog.txt
sed -i -e '/./,$!d' /tmp/changelog.txt
if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi
CL_DIR="fdroid/metadata/de.jeanlucmakiola.calendula/en-US/changelogs"
mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
# App-level control file (Categories/License/links) for the self-hosted
# repo's `fdroid update`.
cp fdroid-metadata/de.jeanlucmakiola.calendula.yml fdroid/metadata/
# Localized text + graphics + per-version changelogs come from the SAME
# fastlane tree the official F-Droid repo harvests from source,
# transformed into the F-Droid repo "localized" layout. One source of
# truth, both channels.
bash scripts/fastlane_to_fdroid_localized.sh \
fastlane/metadata/android \
fdroid/metadata/de.jeanlucmakiola.calendula
- name: Generate F-Droid Index
run: |
@@ -272,97 +263,46 @@ jobs:
SFTP
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
# per-version changelogs) so changelog history survives across
# releases. keystore.p12 and config.yml are NEVER uploaded, so they
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
# releases. keystore.p12 and config.yml are NEVER uploaded.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the Gitea release (it's not an APK, so it fits the
# no-binaries rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: startsWith(github.ref, 'refs/tags/')
continue-on-error: true
# The APK is published and the index re-signed — now record the release.
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries).
- name: Create tag + Gitea release
if: env.IS_RELEASE == 'true'
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
# The release is created by the gitea-release job; ensure it exists
# (idempotent) so this job doesn't race it to a 404.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
"$API/releases" | jq -r '.id // empty')
fi
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid
# repo; the release is the human-readable record. Gated on the tests-only
# ci job (not the deploy) so notes appear even if the F-Droid upload has
# an infrastructure hiccup.
gitea-release:
needs: ci
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract changelog section for this tag
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${TAG#v}"
# Everything between "## [<version>]" and the next "## [" heading.
TAG="v$VERSION"
# Notes = this version's CHANGELOG section.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
# Trim leading blank lines.
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
echo "--- release notes ---"
cat release-notes.md
- name: Create Gitea release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
python3 - "$TAG" <<'PY' > payload.json
python3 - "$TAG" "$SHA" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Upsert: the build-and-deploy job may have created a bare release
# first (to attach the mapping asset), so PATCH the notes if it
# exists, otherwise POST a new one. Both paths are re-run safe.
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true)
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
@@ -376,6 +316,33 @@ jobs:
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)"
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"

View File

@@ -0,0 +1,42 @@
name: Renovate
on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
workflow_dispatch:
# Never let two Renovate runs touch the repo at once.
concurrency:
group: renovate
cancel-in-progress: false
jobs:
renovate:
runs-on: docker
# Run the Renovate image *as* the job container and invoke the `renovate`
# binary directly. The renovatebot/github-action wrapper is a thin Node
# action that shells out to `docker run …` — it needs a Docker CLI + socket
# inside the job, which the Gitea runner's plain node container has not, so
# it died on "Unable to locate executable file: docker". Running the image
# directly drops the docker-in-docker requirement entirely.
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
container:
image: ghcr.io/renovatebot/renovate:43.232.0
steps:
- name: Run Renovate
run: renovate
env:
# Self-hosted Gitea, not github.com.
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# Scope to this repo only — no org-wide autodiscovery.
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
# Commits/PRs authored as the bot, not a real maintainer.
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
LOG_LEVEL: info

View File

@@ -0,0 +1,37 @@
name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
on:
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:
group: translations-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure python3
run: |
if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y python3
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
fi
fi
python3 --version
- name: Check translation parity
run: python3 scripts/check_translations.py

View File

@@ -166,8 +166,12 @@ unblock a later item. Order is a plan, not a contract — revisit after each lan
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
above)*
4. **Per-event color** *(next)* — reuses the calendar color picker/palette; closes the create/edit theme
5. Duplicate event — detail action → prefilled create form; near-free on the tap-to-create prefill infra
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
@@ -221,28 +225,64 @@ Out of scope (no new settings *features* here) — this is a structure + style
pass on the existing controls; new toggles ride in with their own features.
**Tier 2 — navigation & daily-driver completeness**
5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap
6. Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
**Tier 3 — platform reach (depends on Tier 2)**
7. Home-screen widget — built on the agenda data source from #6
8. App shortcuts (launcher long-press → New event); cheap, optional quick-settings tile
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
**Tier 4 — interop & bigger-ticket**
9. Share event as .ics + receive/open .ics into a prefilled create form
10. Default reminder applied to new events; then snooze/dismiss notification actions
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
default reminder **+ per-calendar override**, bundled with battery-exemption
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
four surfaces all shipped: single-event share + whole-calendar backup
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
then `feat/ics-import` (parser, restore, dedup by UID). Import is
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
/ guest lists it can't model. Plans:
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)*
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
decision (snooze must re-fire an alarm).
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
the event model: attendees were already *read* (queried, mapped, shown on the
detail screen since v0.6) and are now *writable* from the form. An attendees
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
no-permission contact picker, edits/removes rows, and sets role (required /
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
caveat was **settled record-only** (Calendula never sends invites; the backend
decides delivery) — full sketch + decision in "Attendee editing" under Locations
& People below.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
- Move event to another calendar — sync-adapter minefield (copy+delete model)
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
**Bottom — deprioritised, not important**
- Duplicate event (detail action → prefilled create form) — moved here
2026-06-17; cheap but low value, pick up only if asked
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
whether drag-drop (#11) jumps ahead given its daily-driver impact.
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
Pulled in opportunistically, not sequenced.
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
full-text search, the "New event" Quick Settings tile, and the now-line all
shipped there. **No tier work is currently committed as next;** the remaining
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
accessibility pass) plus the gated go/no-go items.
## Navigation & views
@@ -252,21 +292,22 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
- Agenda view (fourth view: upcoming events grouped by day; also the
natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1)
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
the view and shrinks the day cells; the badge stays week-view-only.
- Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)*
- Full-text search *(was v3.0)*
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
history, finding an event is core completeness, not optional.
## Event editing & creation
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
dialog) — big-ticket, own slice
- Duplicate event (detail action → prefilled create form)
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
*(next)* — chosen to follow the in-progress tap-to-create + calendar
management work: reuses the color-picker component and palette plumbing
being built for local calendar management, and finishes the create/edit
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
- ~~**Per-event color**~~ *(shipped v2.4.0)*`EVENT_COLOR` / `EVENT_COLOR_KEY`
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
picker in the form, falling back to the calendar color when unset. Reused the
color-picker component and palette plumbing from local calendar management and
finished the create/edit theme.
## Calendars & accounts
@@ -289,32 +330,192 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
go/no-go gate as the OSM/INTERNET item below.
- Move event to another calendar (copy+delete model with a consequences
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0**
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
backup. Settings → Calendars → Export writes every event to a user-chosen
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
UID). Closed the silent data-loss gap.
## Reminders, round two
### Disable a calendar in-app *(captured 2026-06-25)*
- Snooze + dismiss actions on the notification (snooze needs an
exact-alarm / WorkManager decision)
- Settings default reminder applied to new events
A second, heavier visibility level **above** the existing per-view filter. Today
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
a calendar's *events* from the month/week/day/agenda views — the calendar itself
still clutters the drawer filter list, the event-form calendar picker, and the
import target picker. "Disable" removes a calendar from the app's surfaces
entirely; "hide" stays the lightweight, frequently-toggled control.
**Two-level model (both kept):**
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
drawer. Toggles events on/off in the views; the calendar stays listed
everywhere. Operates only over the *enabled* calendars.
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
filter list, not in the event-form picker, not in the import picker, and its
events never appear (it's gated out of `instances()` like a hidden one). It
remains visible **only** in Settings → Calendars, where the enable/disable
toggle lives, so it can be brought back.
**Storage — app-side (DataStore), mirrors the hidden set.** Add
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
a Calendula-local preference, so other calendar apps are unaffected and the sync
adapters stay out of it (privacy-clean, reversible).
**Where the disabled set is applied:**
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
`calendarId ∈ (hidden disabled)` so a disabled calendar's events never show
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
the screens that need everything still get everything.)
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
(you can't hide/show what's disabled).
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
into a calendar you've removed from the app. Handle the last-used-calendar
preselect falling on a now-disabled calendar (fall back to first enabled
writable).
- `ImportViewModel` — exclude disabled from the import target list.
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
row on the existing `CalendarsScreen` (both the local and the synced/read-only
groups — disabling is an app-side view choice, independent of write access).
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
**Decided behaviour:**
- All calendars disabled → views show the existing empty state.
- A disabled calendar that still holds events: events simply vanish from
views/search until re-enabled (no data touched — it's a filter, not a delete).
- Deep links / notifications pointing at an event in a disabled calendar still
open its detail screen — detail is a direct id lookup, not an `instances()`
query, so disabling never strands an existing link.
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
Two themes bundled because both are "make reminders trustworthy" — the core of
the "Calendula is your only calendar app" promise.
**Built in this slice (A + the safe half of B):** global timed default reminder
+ a **separate all-day default** (day-scale lead times) + per-calendar override
(timed events), applied on create with manual-edit / calendar-switch / all-day-
toggle handling; three pickers + per-calendar override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link, no
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
Resolution model: all-day events use the all-day global default outright;
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
applied: form-reset state race, label-fn consolidation with the detail screen,
inline wrapper + single combined flow read.
**Deliberately deferred (documented decisions, not oversights):**
- *Absolute time-of-day for all-day reminders* — the all-day default is still
minutes-before-midnight (day-scale presets), not "9am the day before" (open
decision #2's richer half). Per-calendar all-day overrides also deferred.
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
(open decision #1). The battery exemption is the reliability lever; no
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
exemption lives only in Settings for now (onboarding flow untouched to keep
the change reviewable).
### A. Default reminders (global + per-calendar override)
**No provider backing.** `CalendarContract` has no column that auto-applies a
default reminder per calendar — Google's per-calendar defaults live server-side.
So both the global default *and* the per-calendar override are **app-side
preferences**, applied by us at event-insert time. We inherit nothing from the
synced calendar.
- **Storage (DataStore):**
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
reminders are expressed as minutes before midnight / day-before-at-time, not
minutes before a start instant — they need their own value).
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
**absent key = inherit global**, explicit `null` = "no reminder for this
calendar". (Same for an all-day override map if we want per-calendar all-day.)
- **Apply on create:** a fresh event prefills its reminders list from
override-or-global for the preselected calendar. Changing the calendar in the
form re-applies the *new* calendar's default **only if the user hasn't manually
edited the reminders** — track a dirty flag, mirroring the per-event-color
reset pattern (v2.4).
- **Edit semantics:** defaults apply to **new events only**; never rewrite
reminders on existing events on open or on calendar-switch-during-edit.
- **Settings UI (Notifications sub-page):**
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
/ 1 h / 1 day / custom), plus the separate all-day default.
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
or a Notifications subsection), each opening the same OptionCard with a
leading **"Use global default"** option.
### B. Delivery reliability (exact alarms + battery)
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
it directly undermines the feature's premise, so it rides in here.
- **Exact alarm — decision first:** trust the provider broadcast, or
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
fallback for API 3132 (user-revocable → settings deep-link prompt).
- **Battery-optimization exemption:** a *soft, optional* prompt via
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
auto-grant intent), honest copy: "Android may delay reminders to save battery;
exempt Calendula for on-time delivery." Shown once after the existing
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
are notorious for suppressing it).
### Decisions made (as shipped in v2.6.0)
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
battery-optimisation exemption is the reliability lever (simplicity + battery
cost won over the exact-alarm subsystem).
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
time-of-day ("9am the day before") deferred — see the deferred list above.
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
governing timed events only.
### Round two
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
## Sharing & interop
- Share event as .ics + open/receive .ics into a prefilled create form
(front-runs the import below)
- ICS file import (drag-and-drop) *(was v3.0, optional)*
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
event prefills the create form, many events opens a bulk import (dedup by UID)
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
flow above (single → form, many → bulk import)
## Platform & launchers
- Home-screen widget *(was v3.0)*
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
## Quality & reliability
- **Accessibility pass** — TalkBack content descriptions across all screens,
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
F-Droid app; nothing tracks it yet.
- **Reminder delivery reliability** — exact alarms + battery-optimization
exemption; specced in the "Reminders — defaults & delivery reliability" slice
above (Tier 4 #9).
## Locations & People *(go/no-go, captured 2026-06-11)*
Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet:
- **Contact address picker** for the location field via the system picker
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
fits the privacy story. Same mechanism later for picking emails.
- ~~**Contact address picker** for the location field via the system picker
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
the privacy story. The same no-permission mechanism was then reused for the
attendee email picker (v2.8.0).
- **OSM address autocomplete** in the location field (type "Brandenburger
Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the
@@ -324,9 +525,57 @@ in detail yet:
trade-off is an explicit go/no-go decision before any work starts.
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
if the picker proves clunky.
- **Attendee editing / invites from contacts** — own milestone; writing
`Attendees` rows touches sync-adapter invitation behavior (Google vs
DAVx5 differ).
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
Tier 5 #12, high-importance; the no-permission typed-email path is not an
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
The last read-only gap in the event model: attendees are read & shown on the
detail screen (since v0.6) but the form can't write them. Make guests editable.
- **Read side already done:** `Attendee` domain model + status/relationship/type
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
there.
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
inline grouped list — each guest a tonal card (avatar, name/email, tappable
Required/Optional role chip, remove), the trailing card an inline email field
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
required/optional type updates; organizer/resource/no-email rows untouched).
Reminders restyled to the same grouped-list pattern in passing. Needs only the
existing `WRITE_CALENDAR` — no new permission.
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
field is email-only (the quick path); the add row also has a **"from contacts"
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI`
the system Contacts app returns a URI to the picked email row, queried for
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
email and name in one tap. The result Intent grants temporary read access, so
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
mechanism as the location address picker above. Self/organizer rows stay
non-editable.
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
and Google decides (third-party attendee writes are historically unreliable
there). Editing is allowed on **any writable calendar** — not gated to local.
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
decision): on a synced calendar show "Calendula doesn't send invitations —
your calendar account may email guests when it syncs"; on a local calendar
show "Stored on this device. No one is notified."
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
state to coax a send — it writes the guest list faithfully and leaves
scheduling entirely to the backend.
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
still no INTERNET) was considered and **deferred** — revisit only if users ask
to notify guests explicitly.
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
resource booking — all carry server round-trips or richer sync semantics.
## Consciously rejected
@@ -334,3 +583,5 @@ in detail yet:
- Natural-language quick entry (high effort, locale-fragile; the prefilled
form already covers fast entry)
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.

View File

@@ -1,15 +1,27 @@
# Calendula — Current State
*Last updated: 2026-06-16*
*Last updated: 2026-06-22*
## Status
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
the calendar manager and the navigation drawer) both shipped 2026-06-16. The
backlog is now organised by theme in `ROADMAP.md`.
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
interop) arc is now done or in flight:
- v2.4.0 per-event colors (2026-06-17)
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
event" launcher shortcut (2026-06-17)
- v2.6.0 default reminders (global + per-calendar override, all-day default,
battery-exemption row) + system per-app language (2026-06-18)
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
(2026-06-18)
- v2.7.1v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
holds crash reports via the public Codeberg tracker (MR !27) + reminder
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
release-cut.
## Progress
@@ -105,13 +117,61 @@ backlog is now organised by theme in `ROADMAP.md`.
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
- Donate button on the About card deferred (target still TBD).
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
the event form. Read/render already resolved `EVENT_COLOR` with a calendar
fallback; this adds the write side and the picker. Palette-backed calendars
(Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and
write `EVENT_COLOR_KEY` so the color round-trips through sync; local
calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE`
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
calendars resets the choice (a key is account-scoped). A settings toggle
("Allow colors on unsupported calendars", off by default) extends the raw
path to synced calendars with no palette, with an honest "may not survive
sync" warning on the picker and in Settings. Color writes flow through
insert / dirty-checked update / occurrence-exception; mapper + form tests.
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
jump-to-date drawer date picker, two home-screen widgets (scrolling
"Upcoming" agenda + month grid), and a "New event" launcher long-press
shortcut
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
separate all-day default + per-calendar override (timed), applied on create
with dirty-flag handling; three pickers + override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link,
no new permission). Plus system per-app language (Android 13+) and an
immediate-effect fix for the in-app language picker
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
`.ics` from the detail screen; back up local calendars (Settings → Calendars
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
create form, many events open a bulk import into a chosen calendar (dedup by
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
a widget R8 keep-rule crash fix
- [x] v2.7.1v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
F-Droid reproducible-build cleanups for the official repo
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
## Next
1. Monitor the F-Droid build/publish for the v2.3.0 tag
2. Decide the "Locations & People" and "remote calendar create/edit"
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
3. **Per-event color** is next — reuses the color picker + palette plumbing
from local calendar management; finishes the create/edit theme.
4. Then agenda view (strategic, backs a future widget); jump-to-date and
duplicate event remain cheap follow-ups. Full ranked sequence in
`ROADMAP.md` → "Near-term sequence".
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
merge-driven pipeline) once on-device review signs off
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
importance, opened 2026-06-22). Attendees are already read & shown on the
detail screen since v0.6; the gap is the write side — make guests editable
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
persisted by diffing `CalendarContract.Attendees`. No new permission for the
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
copy ("your account may email guests when it syncs" on synced calendars, "no one
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
numbers in month, full-text search, accessibility pass). Drag-and-drop
rescheduling is **rejected**.

View File

@@ -5,7 +5,307 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.12.0] — 2026-06-28
### Added
- See past events your way. Two new settings change how events that have already
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
usual, dim them, or hide them from the list entirely. **Dim completed events**
(Settings → Appearance) separately fades finished events in the month and week
views. Both are off by default, an event only counts as finished once it has
actually ended (events still in progress are never dimmed), and the lists
update on their own as the day goes on. Thanks to @ptab for the suggestion
([#12]).
### Changed
- Readable titles on overlapping events. In the week and day views, events that
overlap split a day into slim columns where the title used to be clipped to a
character or two. The title now wraps across as many lines as the block can
fit, and the time is hidden on those narrow blocks so the name can fill the
space — so you can tell events apart without opening each one. Thanks to @ptab
for the suggestion ([#13]).
## [2.11.2] — 2026-06-28
### Added
- Jump straight to typing a title. When you start a new event, Calendula now puts
the cursor in the title field and opens the keyboard right away, so you can type
the name without an extra tap. It's on by default and only affects creating an
event — editing never grabs focus — and a new **Focus title on new event**
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
suggestion ([#10]).
## [2.11.1] — 2026-06-28
### Fixed
- Calendula can now be set as your default calendar app. It registers the
calendar-app intent filters the system uses, so it appears in the chooser when
you tap a date in a launcher or clock — and opening one takes you straight to
that day. Android has no way for an app to make itself the default, so you pick
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
## [2.11.0] — 2026-06-27
### Added
- Start the week on any day. The **Week starts on** setting (Settings →
Appearance) now offers every weekday — not just Monday or Sunday — alongside
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
([#3]).
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
follow the system setting automatically. It applies everywhere times appear —
the week and day timelines, agenda, event details, search and reminders — so
the format is now consistent across the whole app. Thanks to @zmaherdev for
the suggestion ([#6]).
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
Appearance) draws a faint separator at each hour in the week and day views,
making it easier to see when events start and end. Off by default. Thanks to
@zmaherdev for the suggestion ([#5]).
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
range** and **Agenda widget range** settings (Settings → Appearance) let the
agenda screen and its home-screen widget each show just today, the rest of this
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
days — "This week" follows your week-start. The agenda also gains a bar at the
top naming the exact dates in view, with a button to switch the range just for
the current session (it resets when you reopen the app); the bar can be turned
off. Thanks to @zmaherdev for the suggestion ([#4]).
- Automatic backup of local calendars. A new **Automatic backup** option
(Settings → Calendars → Backup) periodically exports your local calendars to an
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
is a one-way export and never touches your synced accounts. Thanks to @shield
for the discussion that inspired it ([#7], [#8]).
- Field icons in the event-form settings. Each optional-field toggle (Settings →
Event form) now shows the same icon the field uses in the new-event form, so
the list is easier to scan.
- Help translate Calendula. A **Help translate** link at the top of **Settings →
App language** opens the project's Weblate, where you can add or improve a
language in your browser — no coding needed. Contributions in any language are
welcome.
### Changed
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
agenda), and the agenda's empty state now reads "You're all caught up".
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
into collapsible, source-branded cards — each account shows its app's icon —
with a per-account menu to enable or disable all of its calendars at once or
open the account in its source app.
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
moved up with the other global reminder options, and the per-calendar reminder
overrides now fold into a single expandable section.
- Moved **Add Quick Settings tile** out of the event-form section to its own
top-level entry in Settings.
## [2.10.0] — 2026-06-25
### Added
- Turn a calendar off across the whole app. Settings → Calendars now has a
switch on every calendar — both your own and synced ones. Switching one off
removes it everywhere: its events disappear from all views and search, and it
drops out of the drawer's hide/show filter, the event-form calendar picker and
the import target picker. Unlike hiding (the quick per-view checkbox in the
drawer), a disabled calendar leaves the app entirely until you turn it back on.
Nothing is deleted and no other app is affected — it's a Calendula-only view
choice — so you can re-enable it any time from the same screen, where disabled
calendars stay listed but dimmed.
- An optional way to support development. Settings → About now has a "Support
development" button that opens Ko-fi in your browser. It's a plain donation
link with no perks attached, and it needs no new permissions — Calendula still
has no internet access of its own and just hands the link to your browser.
## [2.9.0] — 2026-06-25
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).
## [2.8.0] — 2026-06-23
### Added
- Find events fast. A search button in the top bar of every calendar view opens
a search box — type a couple of letters and matching events (by title,
location or description) appear, soonest first with past events below. Tap a
result to open it. Search covers your whole calendar, not just what's on
screen, and skips calendars you've hidden. A recurring event shows its next
occurrence rather than the date the series first started.
- A current-time line in the day and week views. A thin coloured line marks the
present moment across today's column, so you can see at a glance where you are
in the day. It updates every minute and only appears when today is in view.
- Add and remove event guests. The create/edit form now has a Guests section:
add people by email or pick them from your contacts, mark each as required or
optional, and remove them. Calendula never sends invitations itself (it has no
internet access) — it only records the guests; if the event lives on a synced
account, that account may email them when it syncs, and on a local calendar no
one is notified. The form tells you which applies. Picking a guest from
contacts needs no contacts permission.
- Pick a location from your contacts. A contacts button beside the location
field drops a contact's address straight into an event — handy for a meeting
at someone's home or office. Like the guest picker, it needs no contacts
permission.
- A "New event" Quick Settings tile. Add it to your quick settings to jump
straight into the new-event form from anywhere. Settings → New event has a
one-tap button to add the tile (Android 13+); on older versions you can add it
from the quick-settings editor.
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
reminder; snooze hides it and brings it back after a delay you pick in
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
system won't re-post a reminder on its own, so Calendula schedules an exact
alarm to bring a snoozed one back on time.
### Changed
- Event details now show each guest's email beneath their name, instead of only
when no name is available.
- Crash and problem reports now open on the project's public Codeberg tracker,
where anyone can register and file an issue. Nothing is sent automatically —
you still review the report and submit it yourself in the browser.
## [2.7.5] — 2026-06-21
### Changed
- Further build cleanup for the official F-Droid repository: stopped embedding
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
scanner rejects as an extra signing block. No functional or visible changes —
the same app as 2.7.4, just without that Play-oriented metadata blob.
## [2.7.4] — 2026-06-21
### Changed
- Build cleanup that lets Calendula ship in the official F-Droid repository:
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
reproducible build process disallows. No functional or visible changes — this
is the same app as 2.7.3.
## [2.7.3] — 2026-06-21
### Fixed
- Home-screen widgets no longer get stuck on a loading spinner in the published
(F-Droid) release build. They render via Android's background-work system, and
release optimisation (R8) was stripping a helper class it loads by name, so the
render job never ran. Added the missing keep rule — widgets now load normally.
## [2.7.2] — 2026-06-21
### Added
- Crash reporting you control. If Calendula closes unexpectedly, it now captures
a technical report and, on the next launch, offers to send it as an issue on
the project's tracker. Nothing is uploaded automatically — the report stays on
your device until you choose to share it, it contains no personal data or
calendar content (only the app, Android and device versions plus the stack
trace), and you see the full text before sending. There's also a "Report a
problem" entry in Settings, and if the app ever fails to start repeatedly, a
minimal recovery screen still lets you send the report.
## [2.7.1] — 2026-06-21
### Fixed
- Fixed the app crashing immediately on launch whenever calendar access hadn't
been granted yet (a fresh install, or after revoking the permission). The app
set up its live calendar-change listener before the permission screen could
appear, which newer Android versions reject outright — so the app died before
you could grant access. The listener now waits for the permission and attaches
itself the moment it's granted.
## [2.7.0] — 2026-06-18
### Added
- Share a single event as an `.ics` file from the event detail screen — hands a
standard calendar file to any app via the system share sheet.
- Back up your local (device-only) calendars: Settings → Calendars → Export as
`.ics` file writes every event of your on-device calendars to a file you
choose. Local calendars aren't synced anywhere, so this is their only backup.
- Open or share an `.ics` file into Calendula: a single event opens the create
form prefilled for review, while a file with many events (e.g. a backup) opens
a bulk import — pick a calendar and import them all. Re-importing a backup
won't create duplicates (events are matched by their unique identifier), and
anything Calendula can't represent (changed recurring occurrences, guest
lists) is reported rather than silently dropped.
### Fixed
- All-day events that cover a single day (e.g. a birthday) no longer show up on
the following day as well — in the day, week and month views or on the event
detail screen. The extra day came from interpreting the all-day date range in
the device's time zone instead of UTC.
- Fixed the app crashing immediately on every launch in the optimized release
build: release code-shrinking (R8) was stripping a database class the
home-screen widget framework needs, so the app died at startup before showing
anything. Added the missing keep rule.
## [2.6.0] — 2026-06-18
### Added
- App language can now be set from Android's system per-app language settings
(Android 13+), in addition to the in-app picker in Settings — and the app is
set up so further languages can be added by community translators
### Fixed
- Changing the app language in Settings now takes effect immediately; the
picker previously had no effect
## [2.5.0] — 2026-06-17
### Added
- Home-screen widgets (two of them): an "Upcoming" agenda widget — a scrolling
list of the next month of events grouped under day headers, with refresh and
"New event" buttons — and a month-grid widget showing the full month with
today highlighted, connected multi-day event bars, and prev/next/today
navigation. Both reuse the in-app grouping and layout so they match the app
exactly, respect your hidden-calendar choices, and refresh automatically when
the calendar changes or the day rolls over. Tapping a day opens that day;
tapping an event opens its details
- App shortcut: long-press the Calendula icon for a "New event" action that
jumps straight into the create-event form
- Agenda view — a fourth top-level view alongside Month/Week/Day: a
forward-looking list of upcoming events grouped under "Today"/"Tomorrow"/date
headers, reachable from the view switcher
- Jump to date — a "Jump to date" row in the navigation drawer opens a date
picker and moves the active view (Month/Week/Day/Agenda) to the chosen day
## [2.4.0] — 2026-06-17
### Added
- Per-event colors: give a single event its own color, instead of always
inheriting its calendar's. Add the new "Color" field from "More fields" in
the event form. On calendars that publish their own color set — such as
Google — you pick from that calendar's palette, so the color is stored
with the event and shows correctly on every synced device. On local
calendars you pick from Calendula's palette. "Reset" returns an event to
its calendar's color
- A new "Allow colors on unsupported calendars" setting (New event form,
off by default) extends per-event colors to calendars that publish no
color set of their own (some CalDAV). Such a color is kept on the device
and may be dropped or overwritten on that calendar's next sync — a
limitation of those calendars, called out plainly in the setting and on
the color picker
## [2.3.0] — 2026-06-16
@@ -416,3 +716,16 @@ automatically, with zero telemetry and no internet permission.
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
- F-Droid metadata stubs (DE + EN short/full descriptions)
- `.planning/` project-tracking documents
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
[#3]: https://codeberg.org/jlmakiola/calendula/issues/3
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13

View File

@@ -1,6 +1,6 @@
<div align="center">
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
<img src="fastlane/metadata/android/en-US/images/icon.png" width="112" alt="Calendula icon">
<h1>Calendula</h1>
@@ -16,11 +16,17 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
</p>
<p>
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
</p>
</div>
@@ -62,19 +68,27 @@ database, no sync stack reinvented.
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- German and English UI, per-app language setting
- German and English UI, per-app language setting — and [open to community
translations](#-translations)
- **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device
## 📦 Install
Calendula ships through a self-hosted F-Droid repository; every version tag
is built, signed, and published there automatically.
### F-Droid (recommended)
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
Store, …).
2. Add the repository — open this link on your phone, or paste it under
*Settings → Repositories → Add*:
Calendula is on the **official [F-Droid](https://f-droid.org) repository**
just search for **Calendula** in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
### Self-hosted repo (latest builds)
New versions are built, signed, and published to a self-hosted repository the
moment each tag lands — usually a few days ahead of the official repo, which
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
@@ -84,36 +98,27 @@ is built, signed, and published there automatically.
fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
3. Refresh, search for **Calendula**, install. Updates arrive like any
other F-Droid app.
2. Refresh, search for **Calendula**, install.
Alternatively, build from source — see below.
Both channels share the same signing key, so you can switch between them
without reinstalling. Or build from source — see below.
## 🛠 Building
## 📚 Documentation
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
```bash
./gradlew assembleDebug # debug APK
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
## 🌍 Translations
If your default JDK is not 17, set `JAVA_HOME` explicitly.
Calendula ships in German and English, and you're warmly invited to add your
language. Translations are managed on a self-hosted **Weblate**:
## 🏗 Architecture
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
Single-activity Compose app, layered `UI → Repository → DataSource →
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
including the recurring-write and reminder pipelines — lives in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## 🗺 Roadmap
Shipped: read (v1.0), write (v1.1v2.0), reminder delivery (v1.4).
Next up: power-user features — widget, search, tablet layouts. The living
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
history in [CHANGELOG.md](CHANGELOG.md).
No coding needed — register on the Weblate server, pick (or request) a language,
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
## 📜 License

View File

@@ -23,13 +23,13 @@ android {
applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29
targetSdk = 36
// The git tag is the single source of truth for released builds: at
// release time .gitea/workflows/release.yaml derives both fields from
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 20300
versionName = "2.3.0"
// These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21200
versionName = "2.12.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -47,6 +47,11 @@ android {
buildTypes {
release {
// Keep release builds reproducible for F-Droid: don't let AGP embed
// build-environment git metadata (META-INF/version-control-info.textproto),
// whose `revision`/path content varies by build machine and is the only
// thing that otherwise differs from a clean from-source rebuild.
vcsInfo { include = false }
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
@@ -61,6 +66,21 @@ android {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
}
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
// suffix so it installs alongside both the production app (signed with the
// real key) and the debug build. Used to smoke-test a release candidate on
// a real device before tagging — R8-only breakage and first-run/permission
// states don't surface in the unminified debug build, nor on a device that
// already holds the permission. Never published. See docs/RELEASING.md.
create("releaseTest") {
initWith(getByName("release"))
applicationIdSuffix = ".releasetest"
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
}
}
compileOptions {
@@ -72,12 +92,31 @@ android {
compose = true
}
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
// "extra signing block" — so leaving it in blocks publishing to the official
// repo. It lives in the signing block, not the zip entries, so disabling it
// doesn't change the build output (reproducibility is unaffected).
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
informational += "MissingTranslation"
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }
@@ -113,6 +152,12 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)

View File

@@ -4,3 +4,25 @@
# Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.**
# Room database implementations (pulled in transitively via
# androidx.glance:glance-appwidget androidx.work androidx.room).
# The widgets rely on Glance, whose WorkManager backend stores state in a Room
# database. Under R8 full mode (AGP 9 default) the generated *_Impl subclasses
# of RoomDatabase lose their usable no-arg constructor / are marked abstract,
# so Room's reflective instantiation throws InstantiationException and the app
# crashes at startup with "Failed to create an instance of ...WorkDatabase".
# Keep the generated Room database implementations fully intact.
-keep class * extends androidx.room.RoomDatabase { *; }
-dontwarn androidx.room.paging.**
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
# the fully-qualified class name persisted in the WorkSpec, so the class must
# keep both its name and a no-arg constructor. Glance renders every widget
# through a WorkManager worker (androidx.glance.session.SessionWorker) whose
# default merger is androidx.work.OverwritingInputMerger. Under R8 full mode
# (AGP 9 default) that unused no-arg constructor was stripped, so WorkManager
# threw "OverwritingInputMerger has no zero argument constructor", the
# SessionWorker never ran, and widgets were stuck on their loading layout
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
-keep class * extends androidx.work.InputMerger { <init>(...); }

View File

@@ -5,6 +5,24 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Lets the "Reliable delivery" setting open the direct system dialog to
exempt Calendula from battery optimisation (so reminder broadcasts aren't
delayed by Doze). Used only to launch that dialog; falls back to the
battery-optimisation list if the OS declines the direct intent.
-->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 3132 (user-revocable,
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
-->
<uses-permission
android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
@@ -21,10 +39,12 @@
<application
android:name=".CalendulaApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Calendula"
@@ -38,8 +58,80 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Be selectable as the system calendar app. Android has no API for
an app to make itself the default, so registering the filters
launchers and the OS use is what lets the user pick Calendula
from the system chooser when a date action fires (issue #9).
APP_CALENDAR is the "open the calendar app" action; the VIEW
filters below catch a tapped date. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.APP_CALENDAR" />
</intent-filter>
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
time Uri (content://com.android.calendar/time/<epochMillis>);
some surfaces use the time/epoch mime type. We open the day view
on that date (MainActivity.calendarTimeDateOrNull). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:scheme="content"
android:host="com.android.calendar"
android:pathPrefix="/time" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="time/epoch" />
</intent-filter>
<!-- Open a .ics file (file manager / email attachment / browser). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:mimeType="text/calendar" />
<data android:scheme="file" android:mimeType="text/calendar" />
</intent-filter>
<!-- Receive a .ics shared from another app. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/calendar" />
</intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<!-- Standalone surface for a captured crash report. MainActivity routes
here on a startup crash-loop, so it stays clear of the app's Hilt
graph and Compose content. Not exported: launched only by us. -->
<activity
android:name=".ui.crash.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
bind it; the action mirrors the launcher "New event" shortcut. -->
<service
android:name=".qs.NewEventTileService"
android:exported="true"
android:icon="@drawable/ic_qs_new_event"
android:label="@string/qs_tile_new_event_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
no notification itself — a calendar app must (v1.4, Etar model).
Exported: the broadcast arrives from the provider's process. -->
@@ -54,6 +146,71 @@
</intent-filter>
</receiver>
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
re-show alarm. Not exported: only our own notification buttons and
AlarmManager PendingIntents target it. -->
<receiver
android:name=".data.reminders.ReminderActionReceiver"
android:exported="false" />
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
<receiver
android:name=".widget.agenda.AgendaWidgetReceiver"
android:label="@string/widget_agenda_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_agenda" />
</receiver>
<receiver
android:name=".widget.month.MonthWidgetReceiver"
android:label="@string/widget_month_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_month" />
</receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync),
and the system broadcasts the date/time ones at midnight / clock
changes so "today" highlighting rolls over. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data
android:host="com.android.calendar"
android:scheme="content" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DATE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
<!-- Hands .ics files we stage in the cache to other apps via a content
Uri (single-event share). Authority tracks applicationId so the
debug suffix doesn't break getUriForFile. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Persists the per-app language (M4) on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service

View File

@@ -1,11 +1,47 @@
package de.jeanlucmakiola.calendula
import android.app.Application
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Application entry point. Registered as android:name=".CalendulaApp"
* in AndroidManifest.xml. Hilt initializes its component graph here.
*/
@HiltAndroidApp
class CalendulaApp : Application()
class CalendulaApp : Application() {
override fun onCreate() {
super.onCreate()
// Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(this)
reconcileAutoBackup()
}
/**
* Bring the scheduled auto-backup work back in line with the saved settings
* on every launch — re-arms it after a reinstall and, crucially, cancels any
* orphaned work once backup has been turned off.
*/
private fun reconcileAutoBackup() {
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
val prefs = deps.settingsPrefs()
BackupScheduler.apply(
context = this@CalendulaApp,
enabled = prefs.autoBackupEnabled.first(),
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
hasFolder = prefs.autoBackupFolderUri.first() != null,
)
}
}
}

View File

@@ -2,37 +2,84 @@ package de.jeanlucmakiola.calendula
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
// The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a
// tap into the running activity; CalendarHost consumes and clears it.
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
// A navigation a home-screen widget asked for (open a date / start a
// create). Consumed once by CalendarHost, same pattern as the detail key.
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
// 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
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If the app keeps crashing as it starts, the main UI can't be trusted
// to come up — route to the standalone report screen instead of
// re-entering the crashing graph.
if (CrashReporter.isCrashLoop(this)) {
startActivity(
Intent(this, CrashReportActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
)
finish()
return
}
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -43,25 +90,133 @@ class MainActivity : ComponentActivity() {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
// The app-wide clock convention: the time-format preference resolved
// against the device's 24-hour system setting, provided once here so
// every time label reads it via LocalUse24HourFormat.
val context = LocalContext.current
val use24Hour = remember(settings.timeFormat, context) {
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
}
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
)
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
)
}
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
// Keep the report (Settings can still reach it); just
// stop it popping on every launch.
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
}
}
}
}
override fun onResume() {
super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail.
CrashReporter.markHealthy(this)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
}
/**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
* app's own `calendula://` deep-links never match.
*/
private fun Intent.importUriOrNull(): Uri? {
val uri = when (action) {
Intent.ACTION_VIEW -> data
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
else -> null
} ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
if (uri.host == CALENDAR_PROVIDER_HOST) return null
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
/**
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
* <epochMillis>`. Null for any other intent. The matching manifest filter is
* what lets users pick Calendula from the system calendar chooser (issue #9).
*/
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
if (action != Intent.ACTION_VIEW) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "time") return null
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
return Instant.fromEpochMilliseconds(millis)
.toLocalDateTime(TimeZone.currentSystemDefault()).date
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
// An external date tap (launcher/clock) has no widget source, so it opens
// the day view rooted over the default home view (OpenDate source = null).
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
// A widget event tap carries both the occurrence and its source view;
// reminders (no source) fall through to [detailKeyOrNull] instead.
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
eventId = eventId,
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
source = source,
)
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
else -> null
}
}
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
private fun Intent.sourceViewOrNull(): CalendarView? =
getStringExtra(EXTRA_SOURCE_VIEW)
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
private fun Intent.detailKeyOrNull(): LongArray? {
// A widget event tap (source present) is routed through [navRequestOrNull]
// so it can also set the base view; only sourceless reminder taps land here.
if (sourceViewOrNull() != null) return null
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null
return longArrayOf(
@@ -72,9 +227,23 @@ class MainActivity : ComponentActivity() {
}
companion object {
// The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// The [CalendarView] (by name) of the widget a launch came from. Roots the
// in-app back stack in that view; absent for non-widget launches (reminders).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
// Fired by the launcher long-press "New event" shortcut (res/xml/
// shortcuts.xml hardcodes this string — keep the two in sync).
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
/**
* Intent opening the detail screen of one occurrence (reminder
@@ -93,5 +262,43 @@ class MainActivity : ComponentActivity() {
putExtra(EXTRA_END_MILLIS, endMillis)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open the day view anchored on [date], drilled in over the launching
* widget's [source] view (home-screen widgets). Backing out of the day
* returns to [source], then to the default home view.
*/
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri()
putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open one occurrence's detail from a widget event tap, rooting the back
* stack in the widget's [source] view. Same occurrence-key shape as
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
* tap (sets the base view) from a reminder tap (leaves it untouched).
*/
fun openEventIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://create/$date".toUri()
putExtra(EXTRA_CREATE, true)
putExtra(EXTRA_DATE_ISO, date.toString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}

View File

@@ -0,0 +1,176 @@
package de.jeanlucmakiola.calendula.data.backup
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
/**
* Schedules and runs the automatic periodic `.ics` backup of local calendars
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
* everything is local provider reads + a local file write.
*/
object BackupScheduler {
private const val WORK_NAME = "auto-backup"
private const val WORK_NAME_NOW = "auto-backup-now"
/**
* Reconcile the scheduled work with the current settings: enqueue a unique
* periodic request when backup is on and a folder is set, otherwise cancel
* it. WorkManager persists the request across reboots on its own.
*
* The first run is delayed by one interval so it never overlaps an immediate
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
*/
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
val workManager = WorkManager.getInstance(context)
if (!enabled || !hasFolder) {
workManager.cancelUniqueWork(WORK_NAME)
// Also cancel any pending immediate run — otherwise a failing run-now
// keeps retrying after the user has turned backup off.
workManager.cancelUniqueWork(WORK_NAME_NOW)
return
}
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
val interval = intervalMinutes.coerceAtLeast(15L)
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
.setInitialDelay(interval, TimeUnit.MINUTES)
.build()
workManager.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
/**
* Run one export immediately (e.g. right after enabling or changing the
* folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into
* a single run rather than racing each other.
*/
fun runNow(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME_NOW,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<BackupWorker>().build(),
)
}
}
/**
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
* overwriting the previous file. Pulls its collaborators through a Hilt
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
* no custom factory / Application wiring needed. Records the outcome for the
* settings status line, and notifies after repeated failures.
*/
class BackupWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun settingsPrefs(): SettingsPrefs
fun repository(): CalendarRepository
fun exporter(): IcsExporter
}
override suspend fun doWork(): Result {
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
val prefs = deps.settingsPrefs()
// Respect the toggle even for already-queued work: if backup was turned
// off, no-op (and don't retry) so a lingering run can't revive itself.
if (!prefs.autoBackupEnabled.first()) return Result.success()
val folder = prefs.autoBackupFolderUri.first()
?: return Result.failure() // nothing to write to — leave scheduling to settings
val now = System.currentTimeMillis()
return try {
val events = deps.repository().exportEvents()
val content = IcsWriter().writeCalendar(events, Clock.System.now())
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
prefs.recordAutoBackupRun(success = true, atMillis = now)
Result.success()
} catch (e: Exception) {
prefs.recordAutoBackupRun(success = false, atMillis = now)
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
notifyFailure(applicationContext)
}
Log.w(TAG, "Automatic backup failed", e)
Result.retry()
}
}
private fun notifyFailure(context: Context) {
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!canPost) return
val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.backup_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply { description = context.getString(R.string.backup_channel_description) },
)
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
val tap = launch?.let {
PendingIntent.getActivity(
context, 0, it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.backup_failed_title))
.setContentText(context.getString(R.string.backup_failed_text))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.apply { tap?.let(::setContentIntent) }
.build()
try {
manager.notify(NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
Log.w(TAG, "Could not post backup-failure notification", e)
}
}
companion object {
const val BACKUP_FILE_NAME = "calendula-backup.ics"
private const val FAILURE_NOTIFY_THRESHOLD = 2
private const val CHANNEL_ID = "backup"
private const val NOTIFICATION_ID = 2
private const val TAG = "BackupWorker"
}
}

View File

@@ -0,0 +1,70 @@
package de.jeanlucmakiola.calendula.data.calendar
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* Translates an all-day reminder between the **semantic** lead time the UI
* speaks (whole days before the event — "1 day before") and the **raw**
* `CalendarContract.Reminders.MINUTES` offset the provider stores.
*
* Calendula schedules no alarms itself: the provider fires a reminder at
* `DTSTART MINUTES` (the Etar model). An all-day event's DTSTART is **UTC
* midnight** (see [EventWriteTimes]), so a raw `MINUTES = 1440` ("1 day") lands
* on UTC-midnight of the previous day — 02:00 local in CEST, not the morning.
*
* To fire at a chosen wall-clock time we encode that time *into* the offset:
* `MINUTES = UTC-midnight(startDate) (localInstant of [timeOfDayMinutes] on the
* day [semanticMinutes] before)`. The single fixed offset can only be tuned for
* the event's own date, so a recurring all-day series or a post-creation
* timezone change drifts the fire time by the offset delta (±1h across DST) —
* an inherent limit of the provider model, shared by Etar.
*/
private const val MINUTES_PER_DAY = 1_440
private const val MILLIS_PER_MINUTE = 60_000L
/**
* Raw provider `MINUTES` for an all-day reminder set [semanticMinutes] before the
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to 420, meaning
* the provider fires *after* DTSTART; this is valid and must not be clamped.
* A negative [semanticMinutes] is the "provider default" sentinel and passes
* through unchanged.
*/
internal fun toProviderAllDayMinutes(
semanticMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
timeOfDayMinutes: Int,
): Int {
if (semanticMinutes < 0) return semanticMinutes
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
.atZone(zone).toInstant().toEpochMilli()
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
}
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
* and rows written under a different timezone. A negative [rawMinutes] (fire
* after DTSTART) folds to day 0.
*/
internal fun fromProviderAllDayMinutes(
rawMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
): Int {
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
.atZone(zone).toLocalDate()
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
}

View File

@@ -1,9 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
@@ -11,16 +13,24 @@ import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -37,6 +47,43 @@ interface CalendarDataSource {
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail?
/**
* Master/one-off events whose title, description or location contains
* [query] (case-insensitive), across all calendars, newest first. Reads the
* Events table directly so the search is unbounded in time; exception rows
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
*/
fun searchEvents(query: String): List<EventInstance>
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
* account exposes no palette (most local calendars, some CalDAV) — the
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
* which a synced calendar may drop on its next sync.
*/
fun eventColorPalette(calendarId: Long): List<EventColorOption>
/**
* Every master/one-off event of the writable local calendars, mapped for a
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
* rows are excluded (see [EventExportProjection]).
*/
fun exportableEvents(): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
* dedup an `.ics` import so re-importing a backup doesn't double events.
*/
fun existingUids(calendarId: Long): Set<String>
/**
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
* minting one when absent); returns the new `Events._ID`. Reminders are
* written as the file's raw lead minutes (METHOD_ALERT).
*/
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
/**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
@@ -50,24 +97,40 @@ interface CalendarDataSource {
/** Permanently delete a local calendar the app owns, with all its events. */
fun deleteCalendar(id: Long)
/** Insert a new event; returns the new `Events._ID`. */
fun insertEvent(form: EventForm): Long
/**
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
* (minutes from local midnight) is the wall-clock time all-day reminders
* should fire at — encoded into each all-day reminder's provider offset
* (ignored for timed events).
*/
fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long
/**
* Update an existing event (for recurring events: the whole series) to
* match [updated]. [original] is the form as it was prefilled from the
* event, so only fields the user actually changed are written and the
* reminder rows can be diffed instead of wiped.
* [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
)
/**
* Change a single occurrence of a recurring event by inserting a
* modified-occurrence exception at [beginMillis] (the occurrence's
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
* row's `Events._ID`.
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
fun updateOccurrence(
eventId: Long,
beginMillis: Long,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/**
* Change a recurring event from the occurrence at [beginMillis] onwards
@@ -82,6 +145,7 @@ interface CalendarDataSource {
beginMillis: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/**
@@ -110,14 +174,52 @@ class AndroidCalendarDataSource @Inject constructor(
) : CalendarDataSource {
private val resolver: ContentResolver get() = context.contentResolver
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
override fun calendars(): List<CalendarSource> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
CalendarProjection.COLUMNS,
null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
// All access to these two collections is guarded by [observerLock] because
// listeners are registered on the main thread (repository init, via ViewModel
// creation) while [ensureObserversRegistered] runs on the IO dispatcher.
private val observerLock = Any()
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
private val registeredObservers = mutableSetOf<ContentObserver>()
private fun hasCalendarPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
/**
* Attach any not-yet-registered observers to the provider, but only once the
* calendar permission is held. Registering a ContentObserver on the calendar
* provider without that permission throws SecurityException on newer Android
* (it used to silently no-op), which crashed the app at launch — the repo
* registers its observer eagerly, before the permission gate. Called from the
* observed reads ([calendars]/[instances]) so the observer re-attaches the
* first time a screen queries after the permission is granted.
*/
private fun ensureObserversRegistered() {
if (!hasCalendarPermission()) return
synchronized(observerLock) {
if (registeredObservers.size == observers.size) return
observers.values.forEach(::registerObserverLocked)
}
}
private fun registerObserverLocked(obs: ContentObserver) {
if (obs in registeredObservers || !hasCalendarPermission()) return
runCatching {
resolver.registerContentObserver(CalendarContract.CONTENT_URI, true, obs)
}.onSuccess { registeredObservers += obs }
.onFailure { Log.w(TAG, "Calendar observer registration skipped", it) }
}
override fun calendars(): List<CalendarSource> {
ensureObserversRegistered()
return resolver.query(
CalendarContract.Calendars.CONTENT_URI,
CalendarProjection.COLUMNS,
null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
}
/**
* Calendar-row writes must address the provider as a sync adapter and name
@@ -152,7 +254,8 @@ class AndroidCalendarDataSource @Inject constructor(
putDescription(description)
}
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create local calendar '$name'")
// No calendar name in the message — it can reach a crash report.
?: throw WriteFailedException("create local calendar")
return ContentUris.parseId(uri)
}
@@ -190,6 +293,7 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
@@ -202,6 +306,89 @@ class AndroidCalendarDataSource @Inject constructor(
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
override fun searchEvents(query: String): List<EventInstance> {
ensureObserversRegistered()
val trimmed = query.trim()
if (trimmed.isEmpty()) return emptyList()
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
// itself instead of acting as a wildcard.
val escaped = trimmed
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
val like = "%$escaped%"
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
val selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
return resolver.query(
CalendarContract.Events.CONTENT_URI,
SearchProjection.COLUMNS,
selection,
arrayOf(like, like, like),
CalendarContract.Events.DTSTART + " DESC",
)?.use { c ->
val reader = CursorColumnReader(c)
val out = ArrayList<EventInstance>(c.count)
while (c.moveToNext()) {
val base = reader.toSearchResult() ?: continue
// A recurring master's DTSTART is the series start; show its
// nearest occurrence instead so the date is the one the user
// actually cares about (and sorting reflects it).
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
out += if (recurring) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy(
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
)
} ?: base
} else {
base
}
}
out
} ?: emptyList()
}
/**
* The occurrence of [eventId] nearest to now: the soonest upcoming one
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
* within the same window behind. Null when neither exists (e.g. a series
* that only starts further out than the window) — the caller then keeps the
* series-start DTSTART. Returns (begin, end) epoch millis.
*/
private fun nearestOccurrenceMillis(eventId: Long): Pair<Long, Long>? {
val now = System.currentTimeMillis()
return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true)
?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false)
}
private fun occurrenceInWindow(
eventId: Long,
beginMillis: Long,
endMillis: Long,
soonestFirst: Boolean,
): Pair<Long, Long>? {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
return resolver.query(
uri,
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()),
order,
)?.use { c ->
if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1)
}
}
override fun eventDetail(eventId: Long): EventDetail? {
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
@@ -215,13 +402,190 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun insertEvent(form: EventForm): Long {
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query(
CalendarContract.Colors.CONTENT_URI,
arrayOf(CalendarContract.Colors.COLOR_KEY, CalendarContract.Colors.COLOR),
CalendarContract.Colors.ACCOUNT_NAME + " = ? AND " +
CalendarContract.Colors.ACCOUNT_TYPE + " = ? AND " +
CalendarContract.Colors.COLOR_TYPE + " = ?",
arrayOf(
account.name,
account.type,
CalendarContract.Colors.TYPE_EVENT.toString(),
),
null,
)?.use { c ->
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?.sortedBy { it.key }
?: emptyList()
}
override fun exportableEvents(): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Map id → display name for the
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
val names = calendars()
.filter { it.isLocal && it.canModifyContents }
.associate { it.id to it.displayName }
if (names.isEmpty()) return emptyList()
val idList = names.keys.joinToString(",")
return resolver.query(
CalendarContract.Events.CONTENT_URI,
EventExportProjection.COLUMNS,
// Skip soft-deleted rows and exception rows (modified occurrences /
// cancellations) — v1 exports masters + one-offs only.
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
null,
CalendarContract.Events.DTSTART + " ASC",
)?.use { c ->
c.mapAll {
val reader = CursorColumnReader(c)
val eventId = reader.getLong(EventExportProjection.IDX_ID)
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
)
}
} ?: emptyList()
}
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events.UID_2445),
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.UID_2445} IS NOT NULL",
arrayOf(calendarId.toString()),
null,
)?.use { c ->
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
} ?: emptySet()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
val startMillis = event.start.toEpochMillis()
val endMillis = event.end.toEpochMillis()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
// Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none.
put(
CalendarContract.Events.UID_2445,
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
)
put(CalendarContract.Events.TITLE, event.summary.trim())
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, startMillis)
if (event.recurrenceRule == null) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
)
}
// All-day rows live at UTC midnights (the file already encodes them so);
// timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
event.description?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("import event into calendar id=$calendarId")
val eventId = ContentUris.parseId(uri)
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
}
}
return eventId
}
/** Provider DURATION for an imported recurring row: whole days / seconds. */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
}
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
arrayOf(
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.ACCOUNT_TYPE,
),
null, null, null,
)?.use { c ->
if (c.moveToFirst()) {
CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
} else {
null
}
}
private data class CalendarAccount(val name: String, val type: String)
/**
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
* all-day reminder is shifted to fire at [allDayReminderTimeMinutes] local
* (see [toProviderAllDayMinutes]); a timed reminder is its lead time as-is.
*/
private fun providerReminderMinutes(
form: EventForm,
minutes: Int,
allDayReminderTimeMinutes: Int,
): Int = if (form.isAllDay) {
toProviderAllDayMinutes(
semanticMinutes = minutes,
startDate = form.start.date.toJavaLocalDate(),
zone = ZoneId.systemDefault(),
timeOfDayMinutes = allDayReminderTimeMinutes,
)
} else {
minutes
}
/** [form]'s reminders as the distinct raw provider offsets to store. */
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
form.reminders
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
.distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
// A globally-unique UID so a later .ics backup/restore can identify
// the event and not duplicate it on re-import (the provider leaves
// this null for events it didn't sync). Older rows without one fall
// back to a stable synthesised UID at export time (deriveIcsUid).
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
@@ -240,26 +604,43 @@ class AndroidCalendarDataSource @Inject constructor(
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
// A null colour just leaves both columns unset (the event inherits
// its calendar's colour), so only the key/raw cases are written.
when {
form.colorKey != null ->
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
}
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri)
// Best effort (spec §8): the event exists at this point — a reminder
// that fails to attach is logged, not surfaced as a failed create.
form.reminders.distinct().forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
encodedReminders(form, allDayReminderTimeMinutes)
.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
// Guests are best-effort like reminders: a row that fails to attach is
// logged, not surfaced as a failed create. Calendula never sends an
// invitation — it only writes the rows; the backend decides delivery.
insertAttendees(eventId, form.attendees)
return eventId
}
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
override fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
) {
val values = buildEventUpdateValues(
original = original,
updated = updated,
@@ -275,13 +656,25 @@ class AndroidCalendarDataSource @Inject constructor(
if (rows == 0) throw WriteFailedException("update event id=$eventId")
}
// Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about.
// disturb provider rows the form never knew about. The diff is on the
// form's semantic minutes; reconcile works in encoded provider minutes.
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, updated.reminders)
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
// Same untouched-set guard for guests: only reconcile when the form's
// attendees actually changed, so unrelated edits never disturb the
// organizer/resource rows the form doesn't model.
if (updated.attendees.toSet() != original.attendees.toSet()) {
reconcileAttendees(eventId, updated.attendees)
}
}
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues(
form = form,
@@ -295,7 +688,8 @@ class AndroidCalendarDataSource @Inject constructor(
val exceptionId = ContentUris.parseId(uri)
// Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, form.reminders)
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
reconcileAttendees(exceptionId, form.attendees)
return exceptionId
}
@@ -304,16 +698,17 @@ class AndroidCalendarDataSource @Inject constructor(
beginMillis: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
val row = querySeriesRow(eventId)
// From the first occurrence on (or with no rule to split) this is
// just a series update.
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
updateEvent(eventId, original, updated)
updateEvent(eventId, original, updated, allDayReminderTimeMinutes)
return eventId
}
// Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated)
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
truncateSeries(eventId, row, beginMillis)
return newEventId
}
@@ -399,9 +794,11 @@ class AndroidCalendarDataSource @Inject constructor(
}
/**
* Make the event's reminder rows match [targetMinutes]: rows with other
* lead times are deleted, missing ones inserted as best-effort ALERTs
* (like insertEvent). Rows whose minutes survive keep their method.
* Make the event's reminder rows match [targetMinutes] — the raw provider
* offsets to store (already encoded via [encodedReminders], so all-day shifts
* are baked in and the diff matches the stored rows). Rows with other offsets
* are deleted, missing ones inserted as best-effort ALERTs (like insertEvent).
* Rows whose minutes survive keep their method.
*/
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
val target = targetMinutes.toSet()
@@ -426,6 +823,135 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
private fun EventAttendee.key(): String = email.trim().lowercase()
/** Distinct guests by email; blanks dropped (we key and write on the address). */
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
val seen = HashSet<String>()
return filter { it.email.isNotBlank() && seen.add(it.key()) }
}
private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues =
ContentValues().apply {
put(CalendarContract.Attendees.EVENT_ID, eventId)
put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim())
attendee.name.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) }
// A plain guest — never the organizer (we don't fabricate scheduling
// identity; the backend owns that). Invited = awaiting their reply.
put(
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE,
)
put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType())
put(
CalendarContract.Attendees.ATTENDEE_STATUS,
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
)
}
private fun EventAttendee.providerType(): Int = if (optional) {
CalendarContract.Attendees.TYPE_OPTIONAL
} else {
CalendarContract.Attendees.TYPE_REQUIRED
}
/** Best-effort attendee inserts (like reminders); emails are never logged. */
private fun insertAttendees(eventId: Long, attendees: List<EventAttendee>) {
attendees.distinctByEmail().forEach { attendee ->
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
}
private data class AttendeeRow(
val id: Long,
val email: String,
val relationship: Int,
val type: Int,
) {
val key: String get() = email.trim().lowercase()
}
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
CalendarContract.Attendees.CONTENT_URI,
arrayOf(
CalendarContract.Attendees._ID,
CalendarContract.Attendees.ATTENDEE_EMAIL,
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
CalendarContract.Attendees.ATTENDEE_TYPE,
),
CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
AttendeeRow(
id = c.getLong(0),
email = c.getString(1).orEmpty(),
relationship = c.getInt(2),
type = c.getInt(3),
),
)
}
}
} ?: emptyList()
/**
* Make the event's *editable* guest rows match [attendees]. Only rows we
* own — has an email, not the organizer, not a resource — are reconciled;
* everything else the backend put there is left untouched. Matched rows keep
* their response status (only the required/optional type is updated if it
* changed); dropped rows are deleted; new guests inserted. Calendula sends
* nothing — the backend decides if anyone is notified on sync.
*/
private fun reconcileAttendees(eventId: Long, attendees: List<EventAttendee>) {
val target = attendees.distinctByEmail()
val targetByKey = target.associateBy { it.key() }
val editable = queryAttendeeRows(eventId).filter {
it.email.isNotBlank() &&
it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER &&
it.type != CalendarContract.Attendees.TYPE_RESOURCE
}
val editableKeys = editable.map { it.key }.toSet()
editable.forEach { row ->
val want = targetByKey[row.key]
if (want == null) {
resolver.delete(
CalendarContract.Attendees.CONTENT_URI,
CalendarContract.Attendees._ID + " = ?",
arrayOf(row.id.toString()),
)
} else if (row.type != want.providerType()) {
resolver.update(
CalendarContract.Attendees.CONTENT_URI,
ContentValues().apply {
put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType())
},
CalendarContract.Attendees._ID + " = ?",
arrayOf(row.id.toString()),
)
}
}
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
}
private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv ->
forEach { (column, value) ->
@@ -434,7 +960,8 @@ class AndroidCalendarDataSource @Inject constructor(
is String -> cv.put(column, value)
is Long -> cv.put(column, value)
is Int -> cv.put(column, value)
else -> error("Unsupported value for $column: $value")
// Only the type, never the value — a cell value can be event content.
else -> error("Unsupported value type for column '$column': ${value::class.simpleName}")
}
}
}
@@ -467,16 +994,20 @@ class AndroidCalendarDataSource @Inject constructor(
listener()
}
}
observers[listener] = obs
resolver.registerContentObserver(
CalendarContract.CONTENT_URI,
/* notifyForDescendants = */ true,
obs,
)
synchronized(observerLock) {
observers[listener] = obs
// Attach now if we already hold the permission; otherwise it stays
// pending and re-attaches on the first read after the grant.
registerObserverLocked(obs)
}
}
override fun unregisterChangeListener(listener: () -> Unit) {
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
synchronized(observerLock) {
observers.remove(listener)?.let { obs ->
if (registeredObservers.remove(obs)) resolver.unregisterContentObserver(obs)
}
}
}
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
@@ -515,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor(
* together (by account) in the filter sheet and calendar manager.
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
/**
* How far ahead/behind a search looks for a recurring event's nearest
* occurrence (~2 years). Wide enough for everyday series; a series that
* next fires beyond it falls back to its series-start date.
*/
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
}
}

View File

@@ -1,9 +1,13 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
@@ -12,6 +16,19 @@ interface CalendarRepository {
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
/**
* Events whose title, description or location contains [query], with hidden
* calendars removed and newest first. Empty when [query] is blank. Searches
* the whole history/future (see [CalendarDataSource.searchEvents]).
*/
suspend fun searchEvents(query: String): List<EventInstance>
/**
* The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]).
*/
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
/** Create a device-only (LOCAL) calendar the app owns; returns its id. */
suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
@@ -21,6 +38,19 @@ interface CalendarRepository {
/** Permanently delete a local calendar the app owns, with all its events. */
suspend fun deleteCalendar(id: Long)
/**
* Every event of the writable local calendars, ready to serialise into a
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
*/
suspend fun exportEvents(): List<IcsEvent>
/**
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
* UID already exists in the target are skipped (idempotent restore); the
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
*/
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
/** Create a new event from a validated form; returns the new `Events._ID`. */
suspend fun createEvent(form: EventForm): Long

View File

@@ -2,14 +2,20 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
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.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
@@ -27,9 +33,14 @@ import javax.inject.Singleton
class CalendarRepositoryImpl @Inject constructor(
private val dataSource: CalendarDataSource,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : CalendarRepository {
/** The configured wall-clock fire time for all-day reminders, read per write. */
private suspend fun allDayReminderTimeMinutes(): Int =
settingsPrefs.allDayReminderTimeMinutes.first()
private val ticks = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
@@ -45,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
.reQuery { dataSource.calendars() }
.flowOn(io)
// Instances are filtered by the app-side hidden-calendar set (M3): an event
// is dropped whenever the user has hidden its calendar. Re-runs when the
// provider ticks *or* the hidden set changes — toggling a calendar in the
// filter sheet updates every view immediately. [calendars] stays unfiltered
// so the filter sheet can list and re-enable hidden calendars.
// Instances are filtered by the app-side hidden disabled calendar sets
// (M3): an event is dropped whenever the user has hidden *or* disabled its
// calendar. Re-runs when the provider ticks *or* either set changes —
// toggling a calendar in the filter sheet or the calendar manager updates
// every view immediately. [calendars] stays unfiltered so those screens can
// list and re-enable hidden/disabled calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine(
ticks
@@ -61,15 +73,32 @@ class CalendarRepositoryImpl @Inject constructor(
)
},
prefs.hiddenCalendarIds,
) { instances, hidden ->
if (hidden.isEmpty()) instances
else instances.filterNot { it.calendarId in hidden }
}.flowOn(io)
prefs.disabledCalendarIds,
) { instances, hidden, disabled ->
val excluded = hidden + disabled
if (excluded.isEmpty()) instances
else instances.filterNot { it.calendarId in excluded }
}
// hidden and disabled both derive from one DataStore, so toggling
// either makes both re-emit and combine briefly surfaces the same
// list twice — collapse the duplicate so views don't re-render for it.
.distinctUntilChanged()
.flowOn(io)
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) }
override suspend fun createLocalCalendar(
displayName: String,
color: Int,
@@ -88,8 +117,30 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
override suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
): IcsImportSummary = withContext(io) {
val existing = dataSource.existingUids(targetCalendarId)
var imported = 0
var skipped = 0
for (event in events) {
// A known UID means the event is already in this calendar — skip,
// keeping a restore idempotent (no overwrite this pass).
if (event.uid != null && event.uid in existing) {
skipped++
} else {
dataSource.insertImportedEvent(event, targetCalendarId)
imported++
}
}
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
dataSource.insertEvent(form)
dataSource.insertEvent(form, allDayReminderTimeMinutes())
}
override suspend fun updateEvent(
@@ -97,7 +148,7 @@ class CalendarRepositoryImpl @Inject constructor(
original: EventForm,
updated: EventForm,
) = withContext(io) {
dataSource.updateEvent(eventId, original, updated)
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
}
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
@@ -109,7 +160,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
form: EventForm,
): Long = withContext(io) {
dataSource.updateOccurrence(eventId, beginMillis, form)
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
}
override suspend fun updateEventFromOccurrence(
@@ -118,7 +169,9 @@ class CalendarRepositoryImpl @Inject constructor(
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
)
}
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {

View File

@@ -13,6 +13,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
private const val TAG = "EventDetailMapper"
@@ -46,13 +49,19 @@ internal fun ColumnReader.toEventDetailCore(
// localized placeholder, and the edit form must prefill the true value.
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
// The event's own colour (null = inherits the calendar's) is kept apart
// from the resolved display colour: the edit form needs to tell the two
// cases apart, while the instance carries the calendar fallback for display.
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
null
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
val isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0
val instance = EventInstance(
instanceId = eventId,
eventId = eventId,
@@ -60,11 +69,23 @@ internal fun ColumnReader.toEventDetailCore(
title = title,
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
isAllDay = isAllDay,
color = color,
location = getString(EventDetailProjection.IDX_LOCATION),
)
// All-day reminders are stored as a wall-clock-shifted offset (see
// AllDayReminderEncoding); decode back to the whole-day lead time the form
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
// event's date is its UTC date.
val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
val zone = ZoneId.systemDefault()
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
} else {
reminders
}
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
@@ -79,7 +100,7 @@ internal fun ColumnReader.toEventDetailCore(
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = reminders,
reminders = displayReminders,
status = status,
// BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed.
@@ -87,6 +108,8 @@ internal fun ColumnReader.toEventDetailCore(
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
eventColor = eventColor,
eventColorKey = eventColorKey,
)
}

View File

@@ -85,6 +85,9 @@ internal fun buildEventUpdateValues(
if (updated.accessLevel != original.accessLevel) {
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
}
if (updated.colorKey != original.colorKey || updated.color != original.color) {
putAll(eventColorColumns(updated.colorKey, updated.color))
}
val timesChanged = updated.start != original.start ||
updated.end != original.end ||
@@ -134,6 +137,28 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
putAll(eventColorColumns(form.colorKey, form.color))
}
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
* account's palette, so the colour round-trips through sync); a raw [color]
* writes `EVENT_COLOR` and clears any key; "no colour" clears both so the event
* falls back to its calendar's colour. The two are never written together —
* the provider rejects a raw colour on a calendar that publishes a palette,
* which is exactly why palette calendars only ever go through the key.
*/
internal fun eventColorColumns(colorKey: String?, color: Int?): Map<String, Any?> = when {
colorKey != null -> mapOf(CalendarContract.Events.EVENT_COLOR_KEY to colorKey)
color != null -> mapOf(
CalendarContract.Events.EVENT_COLOR_KEY to null,
CalendarContract.Events.EVENT_COLOR to color,
)
else -> mapOf(
CalendarContract.Events.EVENT_COLOR_KEY to null,
CalendarContract.Events.EVENT_COLOR to null,
)
}
/**

View File

@@ -0,0 +1,54 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
* for backup. [reminderMinutes] are the row's raw provider reminder offsets and
* [calendarName] the display name of its calendar (emitted as
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
* MapColumnReader.
*/
internal fun ColumnReader.toIcsEvent(
reminderMinutes: List<Int>,
calendarName: String?,
): IcsEvent {
val eventId = getLong(EventExportProjection.IDX_ID)
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event.
val end = when {
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
}
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
}
return IcsEvent(
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC",
recurrenceRule = rrule,
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = reminderMinutes,
status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
)
}

View File

@@ -74,6 +74,7 @@ internal object EventDetailProjection {
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.EVENT_COLOR_KEY,
)
const val IDX_EVENT_ID = 0
@@ -93,6 +94,87 @@ internal object EventDetailProjection {
const val IDX_ACCESS_LEVEL = 14
const val IDX_EVENT_TIMEZONE = 15
const val IDX_SELF_ATTENDEE_STATUS = 16
const val IDX_EVENT_COLOR_KEY = 17
}
/**
* Master/one-off Events rows for a whole-calendar backup. Unlike
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
* occurrence and cancelled-exception rows are filtered out by the query
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
* exported yet — a documented v1 limit (import skips them too).
*/
internal object EventExportProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.UID_2445,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.RRULE,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.STATUS,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.CALENDAR_ID,
)
const val IDX_ID = 0
const val IDX_UID = 1
const val IDX_TITLE = 2
const val IDX_DTSTART = 3
const val IDX_DTEND = 4
const val IDX_DURATION = 5
const val IDX_ALL_DAY = 6
const val IDX_EVENT_TIMEZONE = 7
const val IDX_RRULE = 8
const val IDX_LOCATION = 9
const val IDX_DESCRIPTION = 10
const val IDX_STATUS = 11
const val IDX_AVAILABILITY = 12
const val IDX_CALENDAR_ID = 13
}
/**
* Master/one-off Events rows matched by a full-text search. Like
* [EventExportProjection] it reads the Events table directly (so the search is
* unbounded in time), carrying DURATION for recurring rows that have no DTEND.
* Colour folds the calendar fallback like [InstanceProjection].
*/
internal object SearchProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_COLOR,
CalendarContract.Events.CALENDAR_COLOR,
CalendarContract.Events.EVENT_LOCATION,
// Recurrence markers: a non-empty RRULE or RDATE means the result should
// display its nearest occurrence, not the series-start DTSTART.
CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE,
)
const val IDX_ID = 0
const val IDX_CALENDAR_ID = 1
const val IDX_TITLE = 2
const val IDX_DTSTART = 3
const val IDX_DTEND = 4
const val IDX_DURATION = 5
const val IDX_ALL_DAY = 6
const val IDX_EVENT_COLOR = 7
const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9
const val IDX_RRULE = 10
const val IDX_RDATE = 11
}
internal object AttendeeProjection {

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
* Instances query this reads the series master, so there is no instance id (the
* event id stands in as the list key) and recurring rows carry DURATION instead
* of DTEND — reconstruct the end the same way the `.ics` export does.
*/
internal fun ColumnReader.toSearchResult(): EventInstance? {
val dtStart = getLong(SearchProjection.IDX_DTSTART)
if (dtStart < 0L) return null
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val rawTitle = getString(SearchProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance(
instanceId = eventId,
eventId = eventId,
calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
title = title,
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(SearchProjection.IDX_LOCATION),
)
}

View File

@@ -0,0 +1,188 @@
package de.jeanlucmakiola.calendula.data.crash
import android.content.Context
import android.content.pm.PackageInfo
import android.os.Build
import androidx.core.content.pm.PackageInfoCompat
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
* exception it writes a self-contained report to the app's private storage and
* then chains to the platform's default handler, so the process still dies
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
* the app holds no `INTERNET` permission. The user submits the report later,
* by hand, as a Gitea issue (see the ui/crash surfaces).
*
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
* version, locale, time, and the stack trace — and **nothing else**: no device
* identifiers, no account names, no calendar/event content, no logcat. The user
* is always shown the full text before it leaves the device.
*/
object CrashReporter {
/**
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
* also catches crashes during startup. The handler swallows nothing — it
* persists, then delegates to the previously-registered handler.
*/
fun install(context: Context) {
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
// Capturing must never mask the original crash, so guard every step.
runCatching {
val now = System.currentTimeMillis()
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
recordCrashTime(appContext, now)
}
previous?.uncaughtException(thread, throwable)
}
}
/** The persisted report from the last crash, or null if there is none. */
fun pendingReport(context: Context): String? {
val file = reportFile(context)
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
}
/**
* Whether to surface the report unprompted (on the next launch): a report
* exists and the user hasn't already waved this one away. Settings reaches
* the report via [pendingReport] regardless, so "Not now" only stops the
* auto-prompt — it doesn't discard the report.
*/
fun shouldPrompt(context: Context): Boolean =
reportFile(context).exists() && !dismissedFile(context).exists()
/** Stop auto-prompting for the current report without discarding it. */
fun dismissPrompt(context: Context) {
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
}
/** Drop the persisted report once the user has reported it (or from Settings). */
fun clearReport(context: Context) {
runCatching { reportFile(context).delete() }
runCatching { dismissedFile(context).delete() }
}
/**
* Whether the app appears to be in a startup crash-loop: at least
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
* can't be trusted to start, so the caller routes straight to the standalone
* report screen instead of re-entering the crashing graph.
*/
fun isCrashLoop(context: Context): Boolean {
val times = readCrashTimes(context)
if (times.size < LOOP_THRESHOLD) return false
val recent = times.sortedDescending()
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
}
/**
* Mark the app as having started successfully, resetting the loop counter so
* an ordinary single crash much later never trips loop detection. The
* pending report itself is kept — only the timing trail is cleared.
*/
fun markHealthy(context: Context) {
runCatching { timesFile(context).delete() }
}
// --- persistence -------------------------------------------------------
private fun writeReport(context: Context, report: String) {
val file = reportFile(context).apply { parentFile?.mkdirs() }
file.writeText(report.take(MAX_REPORT_CHARS))
// A fresh crash should prompt again, even if the previous one was waved away.
runCatching { dismissedFile(context).delete() }
}
private fun recordCrashTime(context: Context, nowMillis: Long) {
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
timesFile(context).apply { parentFile?.mkdirs() }
.writeText(kept.joinToString("\n"))
}
private fun readCrashTimes(context: Context): List<Long> {
val file = timesFile(context)
if (!file.exists()) return emptyList()
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
}
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
private const val CRASH_DIR = "crash"
private const val REPORT_FILE = "last_crash.txt"
private const val TIMES_FILE = "crash_times.txt"
private const val DISMISSED_FILE = "dismissed"
private const val MAX_TIMES = 5
private const val MAX_REPORT_CHARS = 64 * 1024
private const val LOOP_THRESHOLD = 2
private const val LOOP_WINDOW_MS = 10_000L
}
/**
* The allowlist of non-personal facts that go into a crash report. Built from
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
*/
data class CrashContext(
val appVersionName: String,
val appVersionCode: Long,
val sdkInt: Int,
val androidRelease: String,
val manufacturer: String,
val model: String,
val locale: String,
) {
companion object {
fun from(context: Context): CrashContext {
val pkg = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
return CrashContext(
appVersionName = pkg?.versionName ?: "?",
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
sdkInt = Build.VERSION.SDK_INT,
androidRelease = Build.VERSION.RELEASE ?: "?",
manufacturer = Build.MANUFACTURER ?: "?",
model = Build.MODEL ?: "?",
locale = Locale.getDefault().toLanguageTag(),
)
}
}
}
/**
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
* tested. The leading marker doubles as the file's sanity check in
* [CrashReporter.pendingReport].
*/
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
val time = runCatching {
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
}.getOrDefault(nowMillis.toString())
return buildString {
appendLine("Calendula crash report")
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
appendLine("Locale: ${ctx.locale}")
appendLine("Time: $time")
appendLine()
appendLine("Stack trace:")
append(trace)
}
}
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

View File

@@ -0,0 +1,78 @@
package de.jeanlucmakiola.calendula.data.ics
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* The Android IO edge of `.ics` export: writes a serialised calendar to a
* SAF document (whole-calendar backup) or stages it in a cache file behind a
* `FileProvider` content Uri (single-event share). The serialisation itself is
* the pure `domain.ics.IcsWriter`; this class only moves bytes.
*/
@Singleton
class IcsExporter @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** Write [content] to the SAF document at [uri] as UTF-8. Throws on failure. */
fun writeDocument(uri: Uri, content: String) {
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(content.toByteArray(Charsets.UTF_8))
// Only the scheme — the full Uri can embed the user's chosen filename.
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
}
/**
* Write [content] to [fileName] inside the persisted SAF tree [folder],
* overwriting it if it already exists (the automatic-backup destination).
* Requires a persisted write grant on [folder]. Throws on failure.
*/
fun writeToFolder(folder: Uri, fileName: String, content: String) {
val dir = DocumentFile.fromTreeUri(context, folder)
?: throw IOException("Backup folder is not accessible")
if (!dir.exists() || !dir.canWrite()) {
throw IOException("Backup folder is missing or not writable")
}
// Reuse the canonical file if present and clean up any "name (1).ics"
// duplicates SAF may have created if two runs ever raced — so we always
// converge on a single overwritten file.
val base = fileName.substringBeforeLast('.')
val ext = fileName.substringAfterLast('.', "")
var target: DocumentFile? = null
for (child in dir.listFiles()) {
val name = child.name ?: continue
when {
name == fileName -> target = child
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
}
}
val file = target
?: dir.createFile(MIME_CALENDAR, fileName)
?: throw IOException("Could not create backup file in the chosen folder")
writeDocument(file.uri, content)
}
/**
* Stage [content] in a private cache file and return a shareable content
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
* the receiving app. The authority mirrors the manifest's `FileProvider`.
*/
fun stageShareFile(fileName: String, content: String): Uri {
val dir = File(context.cacheDir, SHARE_DIR).apply { mkdirs() }
val file = File(dir, fileName)
file.writeText(content, Charsets.UTF_8)
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
private companion object {
const val SHARE_DIR = "shared_ics"
const val MIME_CALENDAR = "text/calendar"
}
}

View File

@@ -0,0 +1,21 @@
package de.jeanlucmakiola.calendula.data.ics
import android.content.Context
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Android IO edge of `.ics` import: reads the text of a received/opened
* document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
* pulls bytes off the ContentResolver. Returns null on any read failure.
*/
@Singleton
class IcsImporter @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun readText(uri: Uri): String? = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
}.getOrNull()
}

View File

@@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor(
}
}
/**
* App-side preference for "calendars the user has disabled in this app" — a
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
* every surface (drawer filter, event-form picker, import picker) and its
* events never appear; it stays listed only in Settings → Calendars so it can
* be re-enabled. Stored exactly like the hidden set; never touches the
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
*/
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
prefs[DISABLED_IDS_KEY].orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
}
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
store.edit { prefs ->
if (ids.isEmpty()) {
prefs.remove(DISABLED_IDS_KEY)
} else {
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
}
}
}
/**
* The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created.
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
}
}

View File

@@ -4,8 +4,14 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -17,18 +23,52 @@ import javax.inject.Singleton
/** Light/dark override. SYSTEM follows the device setting. */
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** Week-start override. AUTO derives the first day from the active locale. */
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
/**
* Week-start override. [Auto] derives the first day from the active locale;
* [Day] pins a specific weekday (any of the seven).
*/
sealed interface WeekStartPref {
data object Auto : WeekStartPref
data class Day(val day: DayOfWeek) : WeekStartPref
}
/**
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
* locale's convention (e.g. Monday in DE, Sunday in en-US).
* Clock convention for time-of-day labels. AUTO follows the device's 24-hour
* system setting; the others force a 12- or 24-hour clock app-wide.
*/
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
/**
* How the Agenda screen treats events that have already finished today: [SHOW]
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
* them from the list entirely.
*/
enum class PastEventDisplay { SHOW, DIM, HIDE }
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
fun parsePastEventDisplay(
stored: String?,
default: PastEventDisplay = PastEventDisplay.SHOW,
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
/**
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
* device's `DateFormat.is24HourFormat` value).
*/
fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
TimeFormatPref.AUTO -> systemIs24Hour
TimeFormatPref.TWELVE_HOUR -> false
TimeFormatPref.TWENTY_FOUR_HOUR -> true
}
/**
* Resolve the preference to a concrete first-day-of-week. [WeekStartPref.Auto]
* reads the locale's convention (e.g. Monday in DE, Sunday in en-US).
*/
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
is WeekStartPref.Day -> day
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
}
/**
@@ -53,7 +93,7 @@ class SettingsPrefs @Inject constructor(
}
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
parseWeekStart(prefs[WEEK_START_KEY])
}
suspend fun setThemeMode(mode: ThemeMode) {
@@ -65,7 +105,101 @@ class SettingsPrefs @Inject constructor(
}
suspend fun setWeekStart(pref: WeekStartPref) {
store.edit { it[WEEK_START_KEY] = pref.name }
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
}
/** Clock convention for time-of-day labels (v2.11). Defaults to AUTO (system). */
val timeFormat: Flow<TimeFormatPref> = store.data.map { prefs ->
prefs[TIME_FORMAT_KEY].toEnum(TimeFormatPref.AUTO)
}
suspend fun setTimeFormat(pref: TimeFormatPref) {
store.edit { it[TIME_FORMAT_KEY] = pref.name }
}
/**
* Whether the week/day timeline draws a faint separator line at each hour
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
*/
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_HOUR_LINES_KEY] ?: false
}
suspend fun setShowHourLines(enabled: Boolean) {
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
}
/**
* How the Agenda screen treats events that already ended today. Defaults to
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
* or hiding.
*/
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
}
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
}
/**
* Whether the month/week grids fade events that have already finished.
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
*/
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
}
suspend fun setDimCompletedEvents(enabled: Boolean) {
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
* widget's [agendaWidgetRange].
*/
val agendaScreenRange: Flow<AgendaRange> = store.data.map { prefs ->
parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month)
}
suspend fun setAgendaScreenRange(range: AgendaRange) {
store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() }
}
/** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */
val agendaWidgetRange: Flow<AgendaRange> = store.data.map { prefs ->
parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month)
}
suspend fun setAgendaWidgetRange(range: AgendaRange) {
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
}
/**
* Whether the agenda shows its top range bar — the "showing …" header and
* the session range switcher (v2.11). Default ON.
*/
val agendaShowRangeBar: Flow<Boolean> = store.data.map { prefs ->
prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true
}
suspend fun setAgendaShowRangeBar(enabled: Boolean) {
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
* until they pick another. Also the bottom of the in-app view back stack, so
* back from any other view returns here before exiting.
*/
val defaultView: Flow<CalendarView> = store.data.map { prefs ->
prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week)
}
suspend fun setDefaultView(view: CalendarView) {
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
}
/**
@@ -86,6 +220,20 @@ class SettingsPrefs @Inject constructor(
}
}
/**
* 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
* gets a title, so this saves a tap; users who set the time/calendar first
* can turn it off. Only the create form auto-focuses — editing never does.
*/
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
}
suspend fun setAutofocusEventTitle(enabled: Boolean) {
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
}
/**
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
* for users whose only calendar app this is, reminders are essential; the
@@ -99,6 +247,22 @@ class SettingsPrefs @Inject constructor(
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
}
/**
* Whether to offer a custom event colour even on calendars that publish no
* colour palette (most local calendars handle it fine; synced calendars
* without a palette — some CalDAV — may drop or overwrite a raw colour on
* their next sync). Defaults to OFF: such calendars hide the colour picker
* until the user opts in, accepting the limitation. Local calendars and
* palette-backed calendars (Google, …) are unaffected by this flag.
*/
val allowColorOnUnsupportedCalendars: Flow<Boolean> = store.data.map { prefs ->
prefs[ALLOW_COLOR_UNSUPPORTED_KEY] ?: false
}
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
store.edit { it[ALLOW_COLOR_UNSUPPORTED_KEY] = enabled }
}
/**
* Whether the one-time reminder onboarding step (after the calendar
* grant) has been shown — also true for users who tapped "not now".
@@ -111,6 +275,157 @@ class SettingsPrefs @Inject constructor(
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
}
/**
* The default reminder lead time (minutes before start) prefilled on new
* **timed** events. `null` = no default reminder — the prior behaviour, kept
* as the factory default so existing users aren't surprised by reminders they
* never asked for. Stored as a string so "none" is distinct from a numeric
* value (and from an unset key, which is also "none"). Per-calendar overrides
* in [perCalendarReminderOverride] take precedence; all-day events instead use
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
}
suspend fun setDefaultReminderMinutes(minutes: Int?) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
}
/**
* The default reminder lead time prefilled on new **all-day** events, in
* minutes before the start of the day. All-day events want day-scale lead
* times ("1 day before"), so they have their own default rather than reusing
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
* to all-day events — they always use this global value.
*/
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
}
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
}
/**
* Wall-clock time, as minutes from local midnight, at which **all-day**
* reminders fire. All-day events live at UTC midnight, so a raw "1 day
* before" would fire at an off hour (02:00 local in CEST); this time is
* encoded into the provider offset so the reminder lands at, e.g., 09:00 the
* day before instead. Global for every all-day reminder; default 09:00.
* Stored/clamped to a valid 0..1439 minute-of-day.
*/
val allDayReminderTimeMinutes: Flow<Int> = store.data.map { prefs ->
(prefs[ALLDAY_REMINDER_TIME_KEY] ?: DEFAULT_ALLDAY_REMINDER_TIME)
.coerceIn(0, MINUTES_PER_DAY - 1)
}
suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
}
/**
* How long the notification "Snooze" action defers a reminder, in minutes.
* Tapping Snooze cancels the notification and re-posts it after this delay
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
* clamped to at least 1.
*/
val snoozeMinutes: Flow<Int> = store.data.map { prefs ->
(prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
}
suspend fun setSnoozeMinutes(minutes: Int) {
store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) }
}
/**
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global
* timed default for its new events: a `null` value means "no reminder", an int
* means that lead time. A calendar **absent** from the map inherits the global
* default. Serialised as `id=value;id=value`, with `none` for an explicit
* no-reminder override. (All-day events ignore this and use
* [defaultAllDayReminderMinutes].)
*/
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
}
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
store.edit { prefs ->
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
current.applyOverride(calendarId, override)
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
}
}
/**
* Per-calendar overrides of [defaultAllDayReminderMinutes] for **all-day**
* events, with the same semantics as [perCalendarReminderOverride] (absent =
* inherit the global all-day default; present null = no reminder).
*/
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
}
suspend fun setCalendarAllDayReminderOverride(
calendarId: Long,
override: CalendarReminderOverride,
) {
store.edit { prefs ->
val current =
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
current.applyOverride(calendarId, override)
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
}
}
// --- Automatic backup (issue #8) ------------------------------------
/** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */
val autoBackupEnabled: Flow<Boolean> = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false }
suspend fun setAutoBackupEnabled(enabled: Boolean) {
store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled }
}
/** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */
val autoBackupIntervalMinutes: Flow<Long> = store.data.map { prefs ->
(prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL)
}
suspend fun setAutoBackupIntervalMinutes(minutes: Long) {
store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) }
}
/** Persisted SAF tree Uri of the destination folder, or null if unset. */
val autoBackupFolderUri: Flow<String?> = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] }
suspend fun setAutoBackupFolderUri(uri: String?) {
store.edit { prefs ->
if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri
}
}
/** Outcome of the last automatic run, for the settings status line. */
val autoBackupStatus: Flow<BackupStatus> = store.data.map { prefs ->
BackupStatus(
lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L,
lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true,
consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0,
)
}
/** Record an automatic run's outcome; resets the failure streak on success. */
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
store.edit { prefs ->
prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis
prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success
val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0
prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1
}
}
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
@@ -122,13 +437,144 @@ class SettingsPrefs @Inject constructor(
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_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 TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
booleanPreferencesKey("allow_color_unsupported_calendars")
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
internal val DEFAULT_ALLDAY_REMINDER_KEY =
stringPreferencesKey("default_allday_reminder_minutes")
internal val ALLDAY_REMINDER_TIME_KEY =
intPreferencesKey("allday_reminder_time_minutes")
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_allday_reminder_override")
internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run")
internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success")
internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures")
/** Default automatic-backup interval: daily. */
const val DEFAULT_BACKUP_INTERVAL = 1_440L
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
const val MIN_BACKUP_INTERVAL = 30L
}
}
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
data class BackupStatus(
/** Epoch millis of the last run, or 0 if it has never run. */
val lastRun: Long,
val lastSuccess: Boolean,
val consecutiveFailures: Int,
)
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
sealed interface CalendarReminderOverride {
/** No override — the calendar uses the global default. */
data object Inherit : CalendarReminderOverride
/** Explicit "no reminder" for this calendar, regardless of the global default. */
data object None : CalendarReminderOverride
/** A specific lead time in minutes before the event start. */
data class Minutes(val minutes: Int) : CalendarReminderOverride
}
/**
* The lead time to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
* it can be unit-tested.
*/
fun resolveDefaultReminder(
timedGlobal: Int?,
allDayGlobal: Int?,
timedOverrides: Map<Long, Int?>,
allDayOverrides: Map<Long, Int?>,
calendarId: Long?,
isAllDay: Boolean,
): Int? {
val overrides = if (isAllDay) allDayOverrides else timedOverrides
val global = if (isAllDay) allDayGlobal else timedGlobal
return if (calendarId != null && overrides.containsKey(calendarId)) {
overrides[calendarId]
} else {
global
}
}
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
private fun MutableMap<Long, Int?>.applyOverride(
calendarId: Long,
override: CalendarReminderOverride,
) {
when (override) {
CalendarReminderOverride.Inherit -> remove(calendarId)
CalendarReminderOverride.None -> put(calendarId, null)
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
}
}
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
private const val WEEK_START_AUTO = "AUTO"
private fun WeekStartPref.storageValue(): String = when (this) {
WeekStartPref.Auto -> WEEK_START_AUTO
is WeekStartPref.Day -> day.name
}
/**
* Parse the stored week-start value. "AUTO"/null/garbage → [WeekStartPref.Auto];
* a [DayOfWeek] name → [WeekStartPref.Day]. The legacy "MONDAY"/"SUNDAY" enum
* values migrate transparently, since both are valid day names.
*/
private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
null, WEEK_START_AUTO -> WeekStartPref.Auto
else -> DayOfWeek.entries.firstOrNull { it.name == stored }
?.let { WeekStartPref.Day(it) } ?: WeekStartPref.Auto
}
private const val NONE = "none"
private const val ENTRY_SEP = ";"
private const val KEY_VALUE_SEP = "="
private fun String?.toReminderMinutes(): Int? = when (this) {
null, "", NONE -> null
else -> toIntOrNull()
}
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
}.toMap()
}
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -46,7 +46,7 @@ class EventReminderReceiver : BroadcastReceiver() {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
due.forEach(notifier::post)
due.forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now)
}
} finally {

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
* the internal re-show when a snooze elapses. All three are app-internal
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
* alarm), so the receiver is not exported.
*
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
* already fired, so nothing re-posts it.
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
* it after the user's snooze delay.
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
* or dismiss it again.
*/
@AndroidEntryPoint
class ReminderActionReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var scheduler: ReminderSnoozeScheduler
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onReceive(context: Context, intent: Intent) {
val alert = alertFrom(intent) ?: return
when (intent.action) {
ACTION_DISMISS -> notifier.cancel(alert)
ACTION_SNOOZE -> {
// Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work.
notifier.cancel(alert)
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
} finally {
pendingResult.finish()
}
}
}
ACTION_SHOW -> {
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
} finally {
pendingResult.finish()
}
}
}
}
}
companion object {
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
private const val EXTRA_ALERT_ID = "alert_id"
private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_BEGIN = "begin"
private const val EXTRA_END = "end"
private const val EXTRA_TITLE = "title"
private const val EXTRA_LOCATION = "location"
private const val EXTRA_ALL_DAY = "all_day"
/** An explicit intent to this receiver carrying [alert] as extras. */
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId)
putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_BEGIN, alert.beginMillis)
putExtra(EXTRA_END, alert.endMillis)
putExtra(EXTRA_TITLE, alert.title)
putExtra(EXTRA_LOCATION, alert.location)
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
}
/**
* A stable request code per (alert, action) so the three PendingIntents
* of one notification stay distinct and don't clobber each other.
*/
fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) {
ACTION_SNOOZE -> 1
ACTION_DISMISS -> 2
ACTION_SHOW -> 3
else -> 0
}
return alert.alertId.toInt() * 8 + actionOffset
}
private fun alertFrom(intent: Intent): ReminderAlert? {
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
endMillis = intent.getLongExtra(EXTRA_END, 0L),
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
location = intent.getStringExtra(EXTRA_LOCATION),
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
)
}
}
}

View File

@@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import kotlinx.coroutines.flow.first
import java.time.ZoneId
import java.util.Locale
import javax.inject.Inject
@@ -29,6 +32,7 @@ import javax.inject.Singleton
@Singleton
class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs,
) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -39,15 +43,18 @@ class ReminderNotifier @Inject constructor(
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
fun post(alert: ReminderAlert) {
suspend fun post(alert: ReminderAlert) {
ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val time = reminderTimeText(
beginMillis = alert.beginMillis,
endMillis = alert.endMillis,
isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(),
locale = Locale.getDefault(),
is24Hour = is24Hour,
)
val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -61,6 +68,16 @@ class ReminderNotifier @Inject constructor(
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(detailIntent(alert))
.addAction(
R.drawable.ic_notification_snooze,
context.getString(R.string.reminder_action_snooze),
actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
)
.addAction(
R.drawable.ic_notification_dismiss,
context.getString(R.string.reminder_action_dismiss),
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
)
.build()
try {
NotificationManagerCompat.from(context)
@@ -71,6 +88,19 @@ class ReminderNotifier @Inject constructor(
}
}
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
fun cancel(alert: ReminderAlert) {
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
}
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
PendingIntent.getBroadcast(
context,
ReminderActionReceiver.requestCode(alert, action),
ReminderActionReceiver.intent(context, action, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
/* requestCode = */ alert.alertId.toInt(),

View File

@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
*
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
* back to an inexact allow-while-idle alarm only if the OS withholds the
* exact-alarm capability (API 3132 where the user revoked it).
*/
@Singleton
class ReminderSnoozeScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = PendingIntent.getBroadcast(
context,
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
if (canScheduleExact(alarmManager)) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 3132): an inexact wake is the honest
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
* (Calendula is a calendar app), user-revocable on 3132.
*/
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
}

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@@ -25,6 +26,7 @@ fun reminderTimeText(
isAllDay: Boolean,
zone: ZoneId,
locale: Locale,
is24Hour: Boolean,
): String {
if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -40,16 +42,18 @@ fun reminderTimeText(
}
}
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
val timeFormat = timeOfDayFormatter(is24Hour, locale)
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
return if (begin.toLocalDate() == end.toLocalDate()) {
timeFormat.format(begin) + RANGE + timeFormat.format(end)
} else {
val dateTimeFormat = DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
.withLocale(locale)
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
// Cross-day: medium date + the chosen short time, joined per side. Built
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
// applies to the time portion too.
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
dateTime(begin) + RANGE + dateTime(end)
}
}

View File

@@ -30,6 +30,38 @@ data class EventForm(
* those are kept verbatim until the user picks something else.
*/
val rrule: String? = null,
/**
* The event's own colour, or null to inherit the calendar's colour.
* [colorKey] is a `Colors.COLOR_KEY` from the calendar account's published
* event palette (Google, some CalDAV) — written as `EVENT_COLOR_KEY` so it
* round-trips through sync. When it is null but [color] is set, [color] is
* a raw ARGB written as `EVENT_COLOR` (local calendars, or synced ones the
* user opted into despite no palette). [color] mirrors the key's swatch when
* [colorKey] is set, so the picker can highlight it.
*/
val colorKey: String? = null,
val color: Int? = null,
/**
* Guests the user has added/kept on the event. Calendula only writes these
* `Attendees` rows — it has no INTERNET and never sends an invitation
* itself; whether a guest is notified is decided downstream by the
* calendar's backend (local: no one; CalDAV/Google: the server/account).
* Read-only rows the form doesn't model (the organizer, resources) are
* preserved by the data layer, not carried here.
*/
val attendees: List<EventAttendee> = emptyList(),
)
/**
* One editable guest: the user controls the [email] (the identity we dedup and
* write on), an optional display [name], and whether they're [optional] rather
* than required. Response status and the organizer/resource distinction are not
* user-editable, so they aren't modelled here.
*/
data class EventAttendee(
val email: String,
val name: String = "",
val optional: Boolean = false,
)
/**
@@ -43,6 +75,8 @@ enum class EventFormField {
Recurrence,
Availability,
Visibility,
Color,
Attendees,
}
enum class EventFormProblem {
@@ -91,6 +125,29 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
availability = availability,
accessLevel = accessLevel,
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
// The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its
// calendar's colour.
colorKey = eventColorKey,
color = eventColor,
// Only editable guests ride in the form: drop the organizer and
// resource rows (not user-editable) and any without an email (we key
// edits and dedup on the address). The data layer preserves the rows we
// don't carry here, so they're never clobbered on save.
attendees = attendees
.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,
)
}
},
)
}
@@ -100,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
* while the form was open. The raw row times ride along because
* [toEditForm] derives the form's times from the *tapped occurrence*, so
* re-deriving with the same occurrence would mask an externally moved
* event. Not covered (the form can't write them, and the dirty-checked
* write can't clobber them): attendees, status, the user's own response,
* reminder methods, and a recurring event's duration.
* event. Guests are covered (the form writes editable attendees), so an
* external attendee change now also trips the conflict check. Still not
* covered: status, the user's own response, reminder methods, the
* organizer/resource rows, and a recurring event's duration.
*/
data class EditSnapshot(
val form: EventForm,
@@ -130,6 +188,8 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
if (colorKey != null || color != null) add(EventFormField.Color)
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
}
fun EventForm.problems(): Set<EventFormProblem> = buildSet {

View File

@@ -40,6 +40,14 @@ data class EventInstance(
val location: String?,
)
/**
* Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended)
* is *not* considered ended. All-day events end at the exclusive next-midnight,
* so they only count as ended once their day is fully over.
*/
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
data class EventDetail(
val instance: EventInstance,
val description: String?,
@@ -58,8 +66,25 @@ data class EventDetail(
val eventTimezone: String? = null,
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
/**
* The event's own raw colour (`Events.EVENT_COLOR`), null when the event
* inherits its calendar's colour. Unlike [EventInstance.color] (which
* already folds in the calendar fallback for display) this stays null so
* the edit form can tell "has own colour" from "inherits".
*/
val eventColor: Int? = null,
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
val eventColorKey: String? = null,
)
/**
* One selectable event colour published by a calendar's account
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
* `COLOR_KEY` written as `EVENT_COLOR_KEY` (so the colour survives sync),
* [argb] is the swatch it renders as.
*/
data class EventColorOption(val key: String, val argb: Int)
data class Attendee(
val name: String,
val email: String?,

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventDetail
/**
* Build the [IcsEvent] for sharing a single event. We export the event the user
* is looking at as a **one-off** VEVENT (no RRULE): the detail screen shows one
* occurrence, so "share this event" should hand off exactly that instance, not
* a whole series anchored to a possibly-different DTSTART. Reminders are the
* already-decoded semantic lead times the detail screen holds.
*/
fun EventDetail.toShareIcsEvent(): IcsEvent {
val startMillis = instance.start.toEpochMilliseconds()
return IcsEvent(
uid = deriveIcsUid(existingUid = null, eventId = instance.eventId, dtStartMillis = startMillis),
summary = instance.title,
start = instance.start,
end = instance.end,
isAllDay = instance.isAllDay,
zoneId = eventTimezone?.takeIf { it.isNotBlank() } ?: "UTC",
recurrenceRule = null,
location = instance.location,
description = description,
reminderMinutes = reminders.map { it.minutes },
status = status,
availability = availability,
calendarName = null,
)
}

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.domain.ics
// Android's calendar provider (and Calendula's own writes) use the non-standard
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
// leading T. Matched first; anything else falls through to the general grammar.
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
private val DURATION_GENERAL =
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
/**
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `-PT15M`, `P1W`, …),
* sign-aware. Calendula writes `P<days>D` / `P<seconds>S`, but the parser also
* accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
* `VALARM` triggers round-trip. Unparseable input is treated as zero.
*/
fun parseRfc2445DurationMillis(duration: String?): Long {
if (duration.isNullOrBlank()) return 0L
val s = duration.trim()
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
val unitSeconds = when (m.groupValues[3]) {
"W" -> 7L * 24 * 60 * 60
"D" -> 24L * 60 * 60
else -> 1L // S
}
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
}
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
val days = m.groupValues[3].toLongOrNull() ?: 0L
val hours = m.groupValues[4].toLongOrNull() ?: 0L
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
return m.signum() * totalSeconds * 1_000L
}
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L

View File

@@ -0,0 +1,43 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlin.time.Instant
/**
* A single event ready to be serialised to a `VEVENT`, decoupled from the
* provider so [IcsWriter] stays pure-Kotlin and JVM-testable. [start]/[end] are
* absolute instants; [isAllDay] and [recurrenceRule] decide how they are
* rendered (see [IcsWriter]'s timezone rule).
*/
data class IcsEvent(
/** RFC 5545 UID — globally stable across exports (see [deriveIcsUid]). */
val uid: String,
val summary: String,
val start: Instant,
/** Exclusive end (for all-day: the next-day midnight, as the provider stores it). */
val end: Instant,
val isAllDay: Boolean,
/** IANA zone id (`Events.EVENT_TIMEZONE`); only used for recurring timed events. */
val zoneId: String,
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
val recurrenceRule: String? = null,
val location: String? = null,
val description: String? = null,
/** Reminder lead times in minutes before start (raw provider offsets). */
val reminderMinutes: List<Int> = emptyList(),
val status: EventStatus = EventStatus.Confirmed,
val availability: Availability = Availability.Busy,
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
val calendarName: String? = null,
)
/**
* The UID to export for a provider event. A row that already carries a UID
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
* the event id and its DTSTART so the same legacy event yields the same UID
* across repeated backups — which keeps a later restore from duplicating it.
*/
fun deriveIcsUid(existingUid: String?, eventId: Long, dtStartMillis: Long): String =
existingUid?.trim()?.takeIf { it.isNotEmpty() }
?: "$eventId-$dtStartMillis@calendula"

View File

@@ -0,0 +1,259 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlin.time.Instant
/**
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
* but [uid] is nullable (an incoming event may carry none; the insert layer
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
* how the writer encoded them.
*/
data class ParsedIcsEvent(
val uid: String?,
val summary: String,
val start: Instant,
val end: Instant,
val isAllDay: Boolean,
val zoneId: String,
val recurrenceRule: String? = null,
val location: String? = null,
val description: String? = null,
val reminderMinutes: List<Int> = emptyList(),
val status: EventStatus = EventStatus.Confirmed,
val availability: Availability = Availability.Busy,
val calendarName: String? = null,
)
/** Things the parser dropped rather than failing — surfaced in the import report. */
enum class IcsParseWarning {
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
ModifiedOccurrenceSkipped,
/** A `VEVENT` with no parseable `DTSTART`. */
EventWithoutStartSkipped,
/** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
AttendeesIgnored,
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
UnknownTimezone,
}
data class IcsParseResult(
val events: List<ParsedIcsEvent>,
val warnings: Set<IcsParseWarning>,
)
/** Outcome of a bulk `.ics` import into one calendar. */
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
/**
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
* malformed `VEVENT` is skipped (not fatal), and unsupported constructs
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
* resolved against the OS tz database instead ([deviceZone] is the fallback).
*/
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
fun parse(text: String): IcsParseResult {
val lines = unfoldLines(text)
val events = mutableListOf<ParsedIcsEvent>()
val warnings = mutableSetOf<IcsParseWarning>()
var calendarName: String? = null
var i = 0
while (i < lines.size) {
val line = parseContentLine(lines[i])
if (line == null) { i++; continue }
when {
line.isBegin("VEVENT") -> {
val end = indexOfEnd(lines, i + 1, "VEVENT")
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
?.let(events::add)
i = end + 1
}
line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database.
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
}
line.name == "X-WR-CALNAME" -> {
calendarName = unescapeText(line.value).trim().ifEmpty { null }
i++
}
else -> i++
}
}
return IcsParseResult(events, warnings)
}
private fun parseVevent(
body: List<String>,
fileCalendarName: String?,
warnings: MutableSet<IcsParseWarning>,
): ParsedIcsEvent? {
var uid: String? = null
var summary = ""
var dtStart: IcsDateTime? = null
var dtEnd: IcsDateTime? = null
var duration: String? = null
var rrule: String? = null
var location: String? = null
var description: String? = null
var status = EventStatus.Confirmed
var availability = Availability.Busy
var calendarName = fileCalendarName
val reminders = mutableListOf<Int>()
var skipAsOverride = false
var i = 0
while (i < body.size) {
val line = parseContentLine(body[i])
if (line == null) { i++; continue }
when (line.name) {
"BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
val end = indexOfEnd(body, i + 1, "VALARM")
parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
i = end + 1
continue
}
"UID" -> uid = line.value.trim().ifEmpty { null }
"SUMMARY" -> summary = unescapeText(line.value)
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
"DURATION" -> duration = line.value.trim()
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
"STATUS" -> status = mapIcsStatus(line.value)
"TRANSP" -> availability =
if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
else Availability.Busy
"RECURRENCE-ID" -> skipAsOverride = true
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
"X-CALENDULA-CALENDAR" ->
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
}
i++
}
if (skipAsOverride) {
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
return null
}
val start = dtStart ?: run {
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
return null
}
val end = dtEnd
?: duration?.let {
start.copy(
instant = Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
),
)
}
?: start
return ParsedIcsEvent(
uid = uid,
summary = summary,
start = start.instant,
end = end.instant,
isAllDay = start.isAllDay,
zoneId = start.zoneId,
recurrenceRule = rrule,
location = location,
description = description,
reminderMinutes = reminders.distinct(),
status = status,
availability = availability,
calendarName = calendarName,
)
}
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
private fun parseAlarmMinutes(body: List<String>): Int? {
val trigger = body.asSequence()
.mapNotNull { parseContentLine(it) }
.firstOrNull { it.name == "TRIGGER" }
?: return null
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
val millis = parseRfc2445DurationMillis(trigger.value)
// Negative = before start (the normal case) → positive lead minutes.
return (-millis / 60_000L).toInt().coerceAtLeast(0)
}
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
val raw = line.value.trim()
val isDate = line.params["VALUE"].equals("DATE", true) ||
(raw.length == 8 && !raw.contains('T'))
if (isDate) {
val date = parseBasicDate(raw) ?: return null
return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
}
val isUtc = raw.endsWith("Z")
val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
val tzid = line.params["TZID"]
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
}
private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
private companion object {
fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true)
/** Index of the matching `END:<component>` at/after [from], or list end. */
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from
while (i < lines.size) {
val line = parseContentLine(lines[i])
if (line != null && line.name == "END" &&
line.value.trim().equals(component, true)
) {
return i
}
i++
}
return lines.size
}
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
"TENTATIVE" -> EventStatus.Tentative
"CANCELLED" -> EventStatus.Cancelled
else -> EventStatus.Confirmed
}
fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = LocalDate(
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
)
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
}
}

View File

@@ -0,0 +1,155 @@
package de.jeanlucmakiola.calendula.domain.ics
/**
* Low-level RFC 5545 text mechanics, kept separate from [IcsWriter] so the
* escaping and folding rules can be tested in isolation. Pure Kotlin — no
* Android, no time handling.
*/
/** iCalendar mandates CRLF line breaks, not the platform separator. */
const val ICS_CRLF: String = "\r\n"
/** RFC 5545 §3.1: content lines SHOULD be folded at 75 octets (excluding the break). */
private const val MAX_OCTETS = 75
/**
* Escape a TEXT value (SUMMARY, DESCRIPTION, LOCATION, …) per RFC 5545 §3.3.11:
* backslash, semicolon and comma are escaped, newlines become the literal `\n`.
* Backslash is handled first so it doesn't double-escape the others' markers.
*/
fun escapeText(value: String): String = buildString(value.length) {
for (ch in value) {
when (ch) {
'\\' -> append("\\\\")
';' -> append("\\;")
',' -> append("\\,")
'\n' -> append("\\n")
'\r' -> Unit // CR is dropped; a lone CRLF in source text folds to one \n
else -> append(ch)
}
}
}
/**
* Fold a single content line to ≤75 octets per physical line, inserting
* `CRLF + space` between segments (the space is part of the 75-octet budget of
* the continuation line, so its content caps at 74). Folding counts UTF-8
* octets, never splitting a multi-byte character across a boundary.
*/
fun foldLine(line: String): String {
if (line.toByteArray(Charsets.UTF_8).size <= MAX_OCTETS) return line
val out = StringBuilder()
var octetsThisLine = 0
var first = true
var i = 0
while (i < line.length) {
val cp = line.codePointAt(i)
val width = Character.charCount(cp)
val piece = line.substring(i, i + width)
val pieceOctets = piece.toByteArray(Charsets.UTF_8).size
// Continuation lines spend one octet on the leading space.
val budget = if (first) MAX_OCTETS else MAX_OCTETS - 1
if (octetsThisLine + pieceOctets > budget) {
out.append(ICS_CRLF).append(' ')
octetsThisLine = 0
first = false
}
out.append(piece)
octetsThisLine += pieceOctets
i += width
}
return out.toString()
}
/**
* Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
* `\,`. A backslash before any other character is dropped, keeping the
* character (lenient — foreign files escape liberally).
*/
fun unescapeText(value: String): String = buildString(value.length) {
var i = 0
while (i < value.length) {
val c = value[i]
if (c == '\\' && i + 1 < value.length) {
when (val next = value[i + 1]) {
'n', 'N' -> append('\n')
else -> append(next) // \\, \;, \, and any other escaped char
}
i += 2
} else {
append(c)
i++
}
}
}
/**
* Reverse of [foldLine] across a whole document: split into physical lines on
* CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
* the previous one (RFC 5545 unfolding). Returns the logical content lines.
*/
fun unfoldLines(text: String): List<String> {
val out = mutableListOf<String>()
for (physical in text.split("\r\n", "\n", "\r")) {
if (physical.isEmpty()) continue
val isContinuation = physical[0] == ' ' || physical[0] == '\t'
if (isContinuation && out.isNotEmpty()) {
out[out.lastIndex] = out.last() + physical.substring(1)
} else {
out.add(physical)
}
}
return out
}
/**
* One unfolded content line split into its property name, parameters and value:
* `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
* `Lunch`. The value is everything after the first colon that isn't inside a
* quoted parameter; param keys are upper-cased, quoted param values unquoted.
* Returns null for a line with no colon.
*/
data class IcsContentLine(val name: String, val params: Map<String, String>, val value: String)
fun parseContentLine(line: String): IcsContentLine? {
var inQuote = false
var colon = -1
for (i in line.indices) {
when (line[i]) {
'"' -> inQuote = !inQuote
':' -> if (!inQuote) { colon = i; break }
}
}
if (colon < 0) return null
val head = splitUnquoted(line.substring(0, colon), ';')
val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
if (name.isEmpty()) return null
val params = buildMap {
for (part in head.drop(1)) {
val eq = part.indexOf('=')
if (eq > 0) {
put(
part.substring(0, eq).trim().uppercase(),
part.substring(eq + 1).trim().removeSurrounding("\""),
)
}
}
}
return IcsContentLine(name, params, line.substring(colon + 1))
}
/** Split on [delimiter] except where it falls inside a double-quoted run. */
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
val parts = mutableListOf<String>()
val current = StringBuilder()
var inQuote = false
for (c in text) {
when {
c == '"' -> { inQuote = !inQuote; current.append(c) }
c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
else -> current.append(c)
}
}
parts.add(current.toString())
return parts
}

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** Default `PRODID` advertising the writer that produced the file. */
const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
/**
* Hand-rolled RFC 5545 serialiser for the subset Calendula models. No iCal
* library: we stay on `kotlinx-datetime` and own the output, exactly as
* `domain/Recurrence.kt` owns RRULE. Pure and JVM-testable — the caller
* supplies [dtStamp] (the export moment) so the writer never reads a clock.
*
* Timezone rule (see plan 05, decision 1):
* - all-day → `VALUE=DATE`, no zone;
* - timed one-off → UTC instant with a `Z` suffix (an instant is an instant);
* - timed recurring → `TZID`-labelled local wall time, so the series stays
* anchored to wall-clock across DST. No `VTIMEZONE` block is emitted; import
* resolves the `TZID` against the OS tz database.
*/
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
val lines = buildList {
add("BEGIN:VCALENDAR")
add("VERSION:2.0")
add("PRODID:$prodId")
add("CALSCALE:GREGORIAN")
events.forEach { appendEvent(it, dtStamp) }
add("END:VCALENDAR")
}
return lines.joinToString(ICS_CRLF, postfix = ICS_CRLF) { foldLine(it) }
}
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
add("BEGIN:VEVENT")
add("UID:${event.uid}")
add("DTSTAMP:${utcStamp(dtStamp)}")
add("SUMMARY:${escapeText(event.summary)}")
appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() }
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
event.location?.takeIf { it.isNotBlank() }
?.let { add("LOCATION:${escapeText(it)}") }
event.description?.takeIf { it.isNotBlank() }
?.let { add("DESCRIPTION:${escapeText(it)}") }
add("STATUS:${statusValue(event.status)}")
add("TRANSP:${transpValue(event.availability)}")
event.calendarName?.takeIf { it.isNotBlank() }
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
appendAlarm(minutes, event.summary)
}
add("END:VEVENT")
}
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
event.isAllDay -> {
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
}
// Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> {
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
} else {
// Unknown zone id → fall back to plain UTC instants.
add("DTSTART:${utcStamp(event.start)}")
add("DTEND:${utcStamp(event.end)}")
}
}
else -> {
add("DTSTART:${utcStamp(event.start)}")
add("DTEND:${utcStamp(event.end)}")
}
}
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
add("BEGIN:VALARM")
add("ACTION:DISPLAY")
add("DESCRIPTION:${escapeText(summary.ifBlank { "Reminder" })}")
add("TRIGGER:${triggerValue(minutes)}")
add("END:VALARM")
}
private companion object {
fun statusValue(status: EventStatus): String = when (status) {
EventStatus.Confirmed -> "CONFIRMED"
EventStatus.Tentative -> "TENTATIVE"
EventStatus.Cancelled -> "CANCELLED"
}
// iCal TRANSP is binary; only an explicitly free event is TRANSPARENT.
fun transpValue(availability: Availability): String =
if (availability == Availability.Free) "TRANSPARENT" else "OPAQUE"
// A lead time of 0 fires at start (PT0M); anything positive is "before".
fun triggerValue(minutes: Int): String =
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
fun utcStamp(instant: Instant): String =
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
fun localStamp(instant: Instant, zone: TimeZone): String =
basic(instant.toLocalDateTime(zone))
fun utcDate(instant: Instant): String {
val dt = instant.toLocalDateTime(TimeZone.UTC)
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
}
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
)
}
}

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
/**
* Prefill the create form from a single parsed `.ics` event (the "open one
* event" path). [calendarId] is left null so the form preselects the last-used
* calendar, exactly like a fresh create — the user confirms the target and
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
* handling (provider all-day times are UTC midnights with an exclusive end).
*/
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) {
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
}
return EventForm(
calendarId = null,
title = summary,
isAllDay = isAllDay,
start = start,
end = end,
location = location.orEmpty(),
description = description.orEmpty(),
reminders = reminderMinutes.distinct().sorted(),
availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)
}

View File

@@ -0,0 +1,43 @@
package de.jeanlucmakiola.calendula.qs
import android.app.PendingIntent
import android.os.Build
import android.service.quicksettings.TileService
import de.jeanlucmakiola.calendula.MainActivity
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Quick Settings tile: tapping it opens the create-event form on today — the
* same action as the launcher "New event" shortcut and the agenda widget's "+".
* A stateless action tile, so there is no on/off state to keep in sync.
*/
class NewEventTileService : TileService() {
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
// the only form available below UpsideDownCake, and is reached only there.
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
override fun onClick() {
super.onClick()
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
val intent = MainActivity.openCreateIntent(this, today)
// Launch only once the device is unlocked: creating an event behind the
// keyguard makes no sense, and the shade can't start an activity over a
// locked screen anyway.
unlockAndRun {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pending = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pending)
} else {
startActivityAndCollapse(intent)
}
}
}
}

View File

@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -12,45 +14,82 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Holds the active top-level view (spec M1) and swaps between the calendar
* Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
*
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it
* (see [selectView]); a widget launch resets the stack to its own view; a date
* tap drills the day view on top. Pressing back pops one level, and the
* base-level [BackHandler] hands off to the system to exit once only the home
* view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse.
*
* [requestedDetailKey] is an externally requested occurrence (a tapped
* reminder notification routed through MainActivity): it opens the detail
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
* so a later recomposition can't re-open it.
* so a later recomposition can't re-open it. (A widget event tap instead arrives
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
*/
@Composable
fun CalendarHost(
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
// Wait for the persisted default view before seeding the stack, so the app
// opens straight on the user's choice instead of flashing a placeholder and
// correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
view = CalendarView.Day
viewStack = viewStack.drillToDay()
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -87,6 +126,12 @@ fun CalendarHost(
var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true }
// Full-text search — its own overlay, opened from each calendar screen's
// top bar. Sits below the detail/edit overlays so tapping a result reveals
// the detail on top and backing out returns to the results.
var showSearch by rememberSaveable { mutableStateOf(false) }
val onOpenSearch = { showSearch = true }
// Calendar manager (reached from Settings) — its own overlay so it slides
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
@@ -115,31 +160,145 @@ fun CalendarHost(
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
// An opened/received .ics file. [ImportScreen] parses it and either opens
// the prefilled create form (one event → [importForm]) or its own bulk
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
importUri = requestedImportUri
onImportConsumed()
}
}
// Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
// top instead of underneath whatever the user had open.
fun dismissCoveringOverlays() {
showSettings = false
showCalendars = false
detailKey = null
editKey = null
importUri = null
importForm = null
}
// 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
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) {
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
onWidgetNavConsumed()
}
is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createDateIso = null
viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut /
// widget) must land on top of whatever is open — the form overlay
// sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them.
dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
heldCreateIso = iso
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
onWidgetNavConsumed()
}
null -> {}
}
}
val slideSpec = rememberCalendarSlideSpec()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || importUri != null ||
importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1)
}
Box(modifier = modifier.fillMaxSize()) {
when (view) {
CalendarView.Week -> WeekScreen(
selectedView = view,
onSelectView = onSelectView,
// Switching between the peer views (month/week/day/agenda) is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = calendarFadeThrough()
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
label = "view-switch",
) { currentView ->
when (currentView) {
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
}
}
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SearchScreen(
onBack = { showSearch = false },
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = view,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
}
@@ -219,5 +378,32 @@ fun CalendarHost(
) {
CalendarsScreen(onBack = { showCalendars = false })
}
// Import flow for an opened/received .ics file. A single event routes
// into the create form (prefilled, for review); many open the picker.
importUri?.let { uri ->
ImportScreen(
uri = uri,
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null
importForm = form
},
)
}
importForm?.let { form ->
EventEditScreen(
initialDateIso = null,
initialForm = form,
onClose = { importForm = null },
onSaved = { importForm = null },
)
}
}
}
/** Persists the view back stack across config change / process death by ordinal. */
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
* nullable [StateFlow] so the host can wait for DataStore's first emission before
* seeding its view back stack — rendering nothing for that first frame rather
* than flashing the old hard-coded Week view and then correcting it.
*/
@HiltViewModel
class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs,
) : ViewModel() {
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
}

View File

@@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.animation.Crossfade
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
@@ -20,11 +23,16 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RootScreen(
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -47,28 +55,40 @@ fun RootScreen(
onDispose { lifecycle.removeObserver(obs) }
}
if (hasPermission) {
// Second onboarding gate (v1.4, one-time): reminder notifications.
// Null until DataStore's first emission — render nothing for that
// frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
when (onboardingDone) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
// Cross-fade the one-time onboarding gates so granting permission / finishing
// onboarding eases into the next screen instead of snapping. A fade carries no
// spatial motion, so it stays appropriate under "remove animations" too.
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
if (granted) {
// Second onboarding gate (v1.4, one-time): reminder notifications.
// Null until DataStore's first emission — render nothing for that
// frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
null -> {}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,34 @@
package de.jeanlucmakiola.calendula.ui
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* A navigation a home-screen widget asked the app to perform when launched.
* Parsed from the launch intent in MainActivity and consumed once by
* [CalendarHost]. Every request carries the [source] view of the widget it came
* from (the agenda widget → [CalendarView.Agenda], the month widget →
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
* backing out of the opened date/event returns to the widget's own view, not the
* default home. (Reminder notifications are not widgets — they keep the separate
* detail-key channel and leave the base view untouched.)
*/
sealed interface WidgetNavRequest {
/**
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
* [source]. A null [source] means the request came from outside the app (a
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
* the default home view instead of a widget's view.
*/
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
data class OpenEvent(
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
val source: CalendarView,
) : WidgetNavRequest
/** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest
}

View File

@@ -0,0 +1,111 @@
package de.jeanlucmakiola.calendula.ui.agenda
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* How far ahead the agenda (screen or widget) shows events, starting at today.
*
* Two flavours:
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
* [Month] (30 days), [Custom] (1365 days). These never degenerate.
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
* through the end of the current week (respecting the week-start preference —
* a Monday start means "everything before next Monday"); [ThisMonth] runs
* through the last day of the current month. These shrink as the period ends.
*/
sealed interface AgendaRange {
data object Day : AgendaRange
data object Week : AgendaRange
data object Month : AgendaRange
data object ThisWeek : AgendaRange
data object ThisMonth : AgendaRange
data class Custom(val days: Int) : AgendaRange
companion object {
/** Allowed bounds for a [Custom] day count. */
const val MIN_CUSTOM_DAYS = 1
const val MAX_CUSTOM_DAYS = 365
}
}
private const val DAYS_PER_WEEK = 7
/**
* Inclusive number of days the window spans, starting at (and including)
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
* [AgendaRange.ThisWeek], the [weekStart] preference.
*/
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
AgendaRange.Day -> 1
AgendaRange.Week -> DAYS_PER_WEEK
AgendaRange.Month -> 30
AgendaRange.ThisWeek -> {
// Days elapsed since the week's first day (0 when today *is* the start),
// so the window is the rest of the week through the day before it repeats.
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
DAYS_PER_WEEK - sinceWeekStart
}
AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
daysInMonth - anchor.day + 1
}
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
}
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
fun AgendaRange.storageValue(): String = when (this) {
AgendaRange.Day -> "DAY"
AgendaRange.Week -> "WEEK"
AgendaRange.Month -> "MONTH"
AgendaRange.ThisWeek -> "THIS_WEEK"
AgendaRange.ThisMonth -> "THIS_MONTH"
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
}
/** Parse a stored value; unknown/garbage falls back to [default]. */
fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when {
stored == "DAY" -> AgendaRange.Day
stored == "WEEK" -> AgendaRange.Week
stored == "MONTH" -> AgendaRange.Month
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
if (days != null) {
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
} else {
default
}
}
else -> default
}
/**
* The concrete span the range covers, starting at [start] through [end]
* (inclusive), for a human-readable header:
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026")
*/
fun agendaRangeWindowSummary(
range: AgendaRange,
start: LocalDate,
end: LocalDate,
locale: Locale,
): String {
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 medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
return when (range) {
AgendaRange.Day -> medium.format(javaStart)
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} ${medium.format(javaEnd)}"
}
}
private const val CUSTOM_PREFIX = "CUSTOM:"

View File

@@ -0,0 +1,511 @@
package de.jeanlucmakiola.calendula.ui.agenda
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.Coffee
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
private val zone = TimeZone.currentSystemDefault()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
val isOnToday = when (val s = state) {
is AgendaUiState.Success -> s.anchor == s.today
else -> true
}
val successState = state as? AgendaUiState.Success
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = anchor,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
viewModel.goToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday,
todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday,
onCreate = { onCreateEvent(anchor, null) },
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
}
}
}
if (showRangePicker) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.agenda_range_override_hint),
selected = successState?.range ?: AgendaRange.Month,
onSelect = viewModel::setRangeOverride,
onDismiss = { showRangePicker = false },
)
}
}
/**
* A compact tonal pill showing the agenda's current range. Tapping it opens the
* range picker as a session-only override. Filled with the primary container
* while an override is active, so the temporary state is obvious.
*/
@Composable
private fun AgendaRangePill(
range: AgendaRange,
isOverride: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val container = if (isOverride) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
val content = if (isOverride) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = container,
contentColor = content,
shape = RoundedCornerShape(50),
modifier = modifier.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
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
* for · Today, 27 Jun 2026" / "This week, 27 Jun 3 Jul" / "This month, June 2026".
*/
@Composable
private fun AgendaRangeBanner(
range: AgendaRange,
start: LocalDate,
end: LocalDate,
modifier: Modifier = Modifier,
) {
val locale = currentLocale()
val window = agendaRangeWindowSummary(range, start, end, locale)
Column(modifier = modifier) {
Text(
text = stringResource(R.string.agenda_range_showing_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${agendaRangeLabel(range)}, $window",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun AgendaContent(
state: AgendaUiState,
pastDisplay: PastEventDisplay,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
AgendaUiState.Loading -> Box(modifier)
is AgendaUiState.Failure -> Box(modifier) {
CalendarFailure(reason = state.reason, onRetry = onRetry)
}
is AgendaUiState.Success -> {
val now by rememberCurrentMinute()
// Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open.
val days = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
state.days
}
if (days.isEmpty()) {
AgendaEmpty(modifier)
} else {
AgendaList(
days = days,
today = state.today,
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
modifier = modifier,
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AgendaList(
days: List<AgendaDay>,
today: LocalDate,
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today)
}
itemsIndexed(
items = day.events,
key = { _, event -> event.instanceId },
) { index, event ->
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
}
@Composable
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = agendaDayLabel(date, today),
style = MaterialTheme.typography.titleSmall,
color = if (date == today) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
@Composable
private fun AgendaEventRow(
event: EventInstance,
position: Position,
dimmed: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title,
summary = agendaTimeSummary(event),
position = position,
minHeight = 64.dp,
leading = {
Box(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
)
},
onClick = onClick,
)
}
@Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Filled.Coffee,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.agenda_empty_title),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
@Composable
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
val relative = when (date) {
today -> stringResource(R.string.agenda_header_today)
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
else -> null
}
val formatted = formatAgendaDate(date)
return if (relative != null) "$relative · $formatted" else formatted
}
/** Time line under the title: "09:00 10:00 · Location", "All day", etc. */
@Composable
private fun agendaTimeSummary(event: EventInstance): String {
val time = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} else {
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
"${formatTime(event.start, is24Hour, locale)} ${formatTime(event.end, is24Hour, locale)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
return "$weekday, ${date.day}. $monthName ${date.year}"
}

View File

@@ -0,0 +1,65 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
/** One calendar day with at least one event, for the agenda list. */
data class AgendaDay(
val date: LocalDate,
/** Events on this day, all-day first then ascending by start time. */
val events: List<EventInstance>,
)
/**
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
* actually carry events). An event that began before [anchor] (ongoing or
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
* day, all-day events sort first, then ascending by start time, then title.
*
* Shared by the Agenda screen and the agenda home-screen widget so both group
* and order identically.
*/
fun groupAgendaDays(
anchor: LocalDate,
instances: List<EventInstance>,
zone: TimeZone,
): List<AgendaDay> =
instances
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
.toSortedMap()
.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
)
}
/**
* State for the Agenda view: a flat, forward-looking list of upcoming events
* grouped by day (only days that actually have events appear).
*/
sealed interface AgendaUiState {
data object Loading : AgendaUiState
data class Failure(val reason: FailureReason) : AgendaUiState
data class Success(
/** First day of the loaded window (today, or a jumped-to date). */
val anchor: LocalDate,
val today: LocalDate,
val days: List<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */
val range: AgendaRange,
/** True when [range] is a temporary in-view override of the saved default. */
val rangeIsOverride: Boolean,
/** Last day the current [range] covers (inclusive), for the range header. */
val rangeEnd: LocalDate,
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
val showRangeBar: Boolean,
) : AgendaUiState
}

View File

@@ -0,0 +1,171 @@
package de.jeanlucmakiola.calendula.ui.agenda
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
// The saved agenda range plus the range-bar visibility toggle.
private val agendaSettings = combine(
settingsPrefs.agendaScreenRange,
settingsPrefs.agendaShowRangeBar,
) { range, showBar -> AgendaSettings(range, showBar) }
// First day of the week, for the calendar-aligned "this week" range.
private val weekStartDay = settingsPrefs.weekStart
.map { it.resolveFirstDay(Locale.getDefault()) }
/**
* How to treat events that already ended today (show / dim / hide). A display
* concern only, so it rides alongside the data state rather than re-querying;
* the screen combines it with a per-minute "now" to fade or drop past rows.
*/
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW,
)
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor
// A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the
// session but resets to the saved default when the app is relaunched.
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
AgendaParams(
anchor = anchor,
range = override ?: settings.range,
rangeIsOverride = override != null && override != settings.range,
weekStart = weekStart,
showRangeBar = settings.showBar,
)
}
.flatMapLatest { params ->
val window = agendaRange(
params.anchor,
params.range.dayCount(params.anchor, params.weekStart) - 1,
zone,
)
combine(
repository.calendars(),
repository.instances(window),
) { calendars, instances ->
buildState(params, calendars, instances)
}
}
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AgendaUiState.Loading,
)
fun goToToday() {
_anchor.value = todayDate
}
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_anchor.value = date
}
/** Temporarily override the agenda range for this session (the bottom-left pill). */
fun setRangeOverride(range: AgendaRange) {
_rangeOverride.value = range
}
private data class AgendaSettings(
val range: AgendaRange,
val showBar: Boolean,
)
private data class AgendaParams(
val anchor: LocalDate,
val range: AgendaRange,
val rangeIsOverride: Boolean,
val weekStart: DayOfWeek,
val showRangeBar: Boolean,
)
private fun buildState(
params: AgendaParams,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): AgendaUiState {
if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone)
val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
return AgendaUiState.Success(
anchor = anchor,
today = todayDate,
days = days,
range = params.range,
rangeIsOverride = params.rangeIsOverride,
rangeEnd = rangeEnd,
showRangeBar = params.showRangeBar,
)
}
}
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
val from = anchor.atStartOfDayIn(zone)
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}

View File

@@ -4,16 +4,17 @@ import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.BackHandler
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -29,25 +30,33 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -61,25 +70,44 @@ 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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@@ -97,7 +125,10 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for
@@ -132,11 +163,21 @@ fun CalendarsScreen(
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
disabledIds = disabledIds,
error = error,
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetDisabled = viewModel::setDisabled,
onSetAccountDisabled = viewModel::setAccountDisabled,
)
}
}
@@ -145,14 +186,28 @@ fun CalendarsScreen(
private fun CalendarsList(
local: List<CalendarSource>,
synced: List<CalendarSource>,
disabledIds: Set<Long>,
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
onSetAutoBackupInterval: (Long) -> Unit,
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit,
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Accounts the user has folded shut; empty = all expanded (keeps every
// calendar visible by default, the section is collapsible for tidiness).
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
var localExpanded by remember { mutableStateOf(true) }
val writeErrorText = stringResource(R.string.calendars_write_error)
LaunchedEffect(error) {
@@ -162,68 +217,195 @@ private fun CalendarsList(
}
}
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let(onExportBackup) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(onSetAutoBackupFolder) }
var showInterval by remember { mutableStateOf(false) }
val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) {
when (val r = backupResult) {
is BackupResult.Success -> {
snackbarHostState.showSnackbar(
context.resources.getQuantityString(
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
),
)
onConsumeBackupResult()
}
BackupResult.Failure -> {
snackbarHostState.showSnackbar(backupFailedText)
onConsumeBackupResult()
}
null -> Unit
}
}
CollapsingScaffold(
title = stringResource(R.string.calendars_title),
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
// Local (device-only) calendars — the calendars the app owns. The
// "Add calendar" entry closes the group as its final row.
SectionHeader(stringResource(R.string.calendars_local_header))
if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty))
}
val localCount = local.size + 1
local.forEachIndexed { index, calendar ->
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, localCount),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(R.string.calendars_edit_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
// What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_disable_hint))
// Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once;
// tapping a calendar row opens its editor.
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
CalendarGroup(
title = stringResource(R.string.calendars_local_header),
expanded = localExpanded,
bodyHasRows = local.isNotEmpty(),
headerDisabled = localDisabled,
leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } },
manageIcon = Icons.Default.Add,
manageLabel = stringResource(R.string.calendars_add),
onManage = onAdd,
onToggleExpand = { localExpanded = !localExpanded },
showToggleAll = local.isNotEmpty(),
allEnabled = local.none { it.id in disabledIds },
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
) {
if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty))
} else {
local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
)
},
onClick = { onEdit(calendar) },
)
},
onClick = { onEdit(calendar) },
)
}
}
}
// Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something to back up.
if (local.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint))
// One connected card: the one-time export on top, then automatic
// backup (and its folder/interval rows when on).
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
},
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
}
GroupedRow(
title = stringResource(R.string.calendars_add),
position = positionOf(local.size, localCount),
leading = { AddAvatar() },
onClick = onAdd,
)
Spacer(Modifier.height(16.dp))
// Synced calendars — read-only, grouped by account, each with a
// per-account "manage in source app" link.
// Synced calendars — read-only, grouped by account. Each account is a
// collapsible group whose header opens the source app (icon) and toggles
// all of its calendars at once (switch).
SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint))
synced
.groupBy { it.accountName.ifBlank { it.accountType } }
.forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType)
cals.forEachIndexed { index, calendar ->
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
leading = { CalendarColorChip(calendar.color) },
)
val expanded = account !in collapsedAccounts
val accountType = cals.first().accountType
val accountDisabled = cals.all { it.id in disabledIds }
Spacer(Modifier.height(16.dp))
CalendarGroup(
title = account,
expanded = expanded,
bodyHasRows = true,
headerDisabled = accountDisabled,
leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } },
manageIcon = Icons.AutoMirrored.Filled.OpenInNew,
manageLabel = stringResource(R.string.calendars_manage_in_app),
onManage = {
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
},
onToggleExpand = {
collapsedAccounts = if (expanded) {
collapsedAccounts + account
} else {
collapsedAccounts - account
}
},
showToggleAll = true,
allEnabled = cals.none { it.id in disabledIds },
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
) {
cals.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow(
title = calendar.displayName,
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
)
},
)
}
}
}
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.calendars_add_account),
position = Position.Alone,
leading = { AddAvatar() },
onClick = {
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
},
}
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = onSetAutoBackupInterval,
onDismiss = { showInterval = false },
)
}
}
@@ -246,10 +428,9 @@ private fun CalendarEditor(
var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
BackHandler(onBack = onClose)
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {
@@ -325,7 +506,12 @@ private fun CalendarEditor(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ColorPalette(selected = color, onSelect = { color = it }, dark = dark)
ColorSwatchRow(
colors = CALENDAR_COLOR_PALETTE,
selected = color,
onSelect = { color = it },
dark = dark,
)
}
EditorCard(
icon = Icons.AutoMirrored.Filled.Notes,
@@ -371,6 +557,30 @@ private fun CalendarEditor(
}
}
/**
* The per-row enable/disable control. Checked = the calendar is shown in the
* app; unchecking disables it (events, filters and pickers all drop it) without
* touching any provider data. Carries its own content description so the toggle
* is self-describing to screen readers even on a dimmed row.
*/
@Composable
private fun EnableSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
private fun dimIf(disabled: Boolean): Modifier =
if (disabled) Modifier.alpha(0.38f) else Modifier
/** Tonal field card matching the event editor's design (icon + content). */
@Composable
private fun EditorCard(
@@ -402,70 +612,180 @@ private fun EditorCard(
}
}
@OptIn(ExperimentalLayoutApi::class)
/**
* One collapsible calendar group rendered as a connected card. The header row is
* the card's top (tap to expand/collapse): a [leading] source mark (the account
* app's logo, or a device chip for local), the group [title] and a trailing
* overflow (⋮) menu holding the account-level actions — enable/disable every
* calendar at once (when [showToggleAll]) and the manage/add action
* ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as
* the rows below via [body].
*
* The header keeps the standard row colour; the calendars it reveals sit one tone
* darker, so the group reads as a header over nested content. Once [headerDisabled]
* (every calendar switched off) the header fades to the disabled emphasis so the
* whole group reads as off — not just via the menu. [bodyHasRows] is false when no
* calendar rows follow (e.g. an empty local group), so the header stays a
* standalone card rather than a top edge with nothing beneath it.
*/
@Composable
private fun ColorPalette(selected: Int, onSelect: (Int) -> Unit, dark: Boolean) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CALENDAR_COLOR_PALETTE.forEach { argb ->
val isSelected = argb == selected
// Show the pastel the calendar will actually render as, not the raw hue.
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(vertical = 4.dp)
.size(40.dp)
.clip(CircleShape)
.background(pastelize(argb, dark))
.then(
if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
)
.clickable { onSelect(argb) },
) {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.Black.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
@Composable
private fun AccountHeader(account: String, accountType: String) {
val context = LocalContext.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
private fun CalendarGroup(
title: String,
expanded: Boolean,
bodyHasRows: Boolean,
headerDisabled: Boolean,
leading: @Composable () -> Unit,
manageIcon: ImageVector,
manageLabel: String,
onManage: () -> Unit,
onToggleExpand: () -> Unit,
showToggleAll: Boolean,
allEnabled: Boolean,
onToggleAll: (Boolean) -> Unit,
body: @Composable ColumnScope.() -> Unit,
) {
GroupedRow(
title = title,
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
dimmed = headerDisabled,
leading = leading,
trailing = {
CalendarGroupMenu(
title = title,
showToggleAll = showToggleAll,
allEnabled = allEnabled,
onToggleAll = onToggleAll,
manageIcon = manageIcon,
manageLabel = manageLabel,
onManage = onManage,
)
},
onClick = onToggleExpand,
)
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Text(
text = account,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = {
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
}) {
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.calendars_manage_in_app))
Column(content = body)
}
}
/**
* The account header's overflow (⋮) menu: the two account-level actions that
* don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the
* manage/add action. A rounded, tonal dropdown matching the app's surfaces, with
* the two actions divided for clear separation.
*/
@Composable
private fun CalendarGroupMenu(
title: String,
showToggleAll: Boolean,
allEnabled: Boolean,
onToggleAll: (Boolean) -> Unit,
manageIcon: ImageVector,
manageLabel: String,
onManage: () -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { open = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = stringResource(R.string.calendars_account_menu_a11y, title),
)
}
DropdownMenu(
expanded = open,
onDismissRequest = { open = false },
shape = RoundedCornerShape(20.dp),
// A distinct tone + a lifted shadow so the menu reads as floating
// above the cards (which sit at surfaceContainerHigh) rather than
// blending into them.
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
tonalElevation = 0.dp,
shadowElevation = 6.dp,
) {
if (showToggleAll) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (allEnabled) R.string.calendars_disable_all
else R.string.calendars_enable_all,
),
)
},
leadingIcon = {
Icon(
if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null,
)
},
onClick = {
open = false
onToggleAll(!allEnabled)
},
)
HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
}
DropdownMenuItem(
text = { Text(manageLabel) },
leadingIcon = { Icon(manageIcon, contentDescription = null) },
onClick = {
open = false
onManage()
},
)
}
}
}
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
/**
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
* round 40dp chip, so each synced account is recognisable at a glance. We load
* whatever app owns the account from [PackageManager] rather than bundling brand
* logos — always accurate, nothing to license. Falls back to a neutral cloud
* chip when no installed app resolves for the account.
*/
@Composable
private fun AddAvatar() {
private fun SourceLogo(accountType: String) {
val context = LocalContext.current
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
if (logo != null) {
Image(
bitmap = logo,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
} else {
LeadingAvatar(Icons.Default.Cloud)
}
}
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager
val candidates = buildList {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
for (pkg in candidates) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
@Composable
private fun LeadingAvatar(icon: ImageVector) {
Box(
modifier = Modifier
.size(40.dp)
@@ -474,7 +794,7 @@ private fun AddAvatar() {
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Add,
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
@@ -502,6 +822,95 @@ private fun HintText(text: String) {
)
}
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
@Composable
private fun rememberFolderName(uriString: String?): String? {
val context = LocalContext.current
return remember(uriString) {
uriString?.let {
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
}
}
}
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
@Composable
private fun backupIntervalLabel(minutes: Long): String {
val duration = when {
minutes % MINUTES_PER_WEEK == 0L ->
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
minutes % MINUTES_PER_DAY == 0L ->
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
minutes % 60L == 0L ->
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
else ->
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
}
return stringResource(R.string.calendars_auto_backup_every, duration)
}
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
stringResource(R.string.calendars_auto_backup_status_failed, relative)
}
}
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
@Composable
private fun BackupIntervalDialog(
currentMinutes: Long,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
val units = stringArrayResource(R.array.backup_interval_units).toList()
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.calendars_auto_backup_interval_min),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = {
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
onDismiss()
}) { Text(stringResource(R.string.reminder_custom_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L
/**
* Pick the app to open for managing a synced calendar's account. The account's
* own authenticator package (resolved from [AccountManager], no permission
@@ -532,15 +941,3 @@ private fun curatedSourcePackage(accountType: String): String? = when {
else -> null
}
/** Google-Calendar-style palette; values are ARGB ints for `Calendars.CALENDAR_COLOR`. */
internal val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
0xFFD50000, // red
0xFFE67C00, // orange
0xFFF6BF26, // amber
0xFF33B679, // green
0xFF0B8043, // dark green
0xFF039BE5, // blue
0xFF3F51B5, // indigo
0xFF8E24AA, // purple
0xFF616161, // graphite
).map { it.toInt() }

View File

@@ -1,21 +1,35 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
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.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import javax.inject.Inject
/**
@@ -26,7 +40,11 @@ import javax.inject.Inject
*/
@HiltViewModel
class CalendarsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -40,11 +58,71 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(),
)
/**
* Calendars the user has disabled in the app. This screen is the only
* surface that lists them, so it both reads the set (to dim the rows) and
* toggles it. Every other surface simply excludes these ids.
*/
val disabledCalendarIds: StateFlow<Set<Long>> =
prefs.disabledCalendarIds
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
/** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled,
settingsPrefs.autoBackupIntervalMinutes,
settingsPrefs.autoBackupFolderUri,
settingsPrefs.autoBackupStatus,
) { enabled, interval, folder, status ->
AutoBackupUiState(enabled, interval, folder, status)
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AutoBackupUiState(),
)
private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
fun consumeError() { _error.value = false }
private val _backupResult = MutableStateFlow<BackupResult?>(null)
val backupResult: StateFlow<BackupResult?> = _backupResult.asStateFlow()
fun consumeBackupResult() { _backupResult.value = null }
/**
* Serialise every event of the writable local calendars into the chosen SAF
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
* in [backupResult] for a one-shot message.
*/
fun exportBackup(uri: Uri) {
viewModelScope.launch {
_backupResult.value = try {
val count = withContext(io) {
val events = repository.exportEvents()
icsExporter.writeDocument(
uri = uri,
content = IcsWriter().writeCalendar(events, Clock.System.now()),
)
events.size
}
BackupResult.Success(count)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
BackupResult.Failure
}
}
}
fun createCalendar(displayName: String, color: Int, description: String?) = write {
repository.createLocalCalendar(displayName, color, description)
}
@@ -57,6 +135,76 @@ class CalendarsViewModel @Inject constructor(
repository.deleteCalendar(id)
}
/**
* Enable or disable a calendar app-side. Disabling removes it from every
* surface but Settings → Calendars (and hides its events) without touching
* provider data — purely a reversible Calendula-local view choice.
*/
fun setDisabled(id: Long, disabled: Boolean) {
viewModelScope.launch {
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + id else current - id
if (next != current) prefs.setDisabledCalendarIds(next)
}
}
/**
* Enable or disable every calendar of one account in a single write — the
* "toggle all" affordance on an account header. Done as one set update so the
* per-calendar [setDisabled] calls can't race each other.
*/
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
viewModelScope.launch {
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + ids else current - ids.toSet()
if (next != current) prefs.setDisabledCalendarIds(next)
}
}
// --- Automatic backup (issue #8) ------------------------------------
fun setAutoBackupEnabled(enabled: Boolean) {
viewModelScope.launch {
settingsPrefs.setAutoBackupEnabled(enabled)
reschedule()
// Give immediate feedback when turning it on with a folder already set.
if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) {
BackupScheduler.runNow(context)
}
}
}
fun setAutoBackupIntervalMinutes(minutes: Long) {
viewModelScope.launch {
settingsPrefs.setAutoBackupIntervalMinutes(minutes)
reschedule()
}
}
/** Persist the chosen destination folder (taking a durable write grant) and run once. */
fun setAutoBackupFolder(uri: Uri) {
viewModelScope.launch {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
settingsPrefs.setAutoBackupFolderUri(uri.toString())
reschedule()
if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context)
}
}
private suspend fun reschedule() {
BackupScheduler.apply(
context = context,
enabled = settingsPrefs.autoBackupEnabled.first(),
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
)
}
private inline fun write(crossinline block: suspend () -> Unit) {
viewModelScope.launch {
try {
@@ -69,3 +217,17 @@ class CalendarsViewModel @Inject constructor(
}
}
}
/** Automatic-backup settings + last-run status for the Backup section. */
data class AutoBackupUiState(
val enabled: Boolean = false,
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
val folderUri: String? = null,
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
)
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
sealed interface BackupResult {
data class Success(val eventCount: Int) : BackupResult
data object Failure : BackupResult
}

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
/** One UTC day in milliseconds — the unit the M3 [DatePicker] speaks. */
const val MILLIS_PER_DAY: Long = 86_400_000L
/**
* The app's standard Material 3 date picker, opened on [initial] and reporting
* the chosen day through [onConfirm]. Shared by the event form (start/end date,
* RRULE until) and the drawer's jump-to-date action.
*
* DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the
* conversion zone-proof in both directions.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarDatePickerDialog(
initial: LocalDate,
onConfirm: (LocalDate) -> Unit,
onDismiss: () -> Unit,
) {
val state = rememberDatePickerState(
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
state.selectedDateMillis?.let { millis ->
onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt()))
}
},
) { Text(stringResource(R.string.dialog_ok)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
) {
DatePicker(state = state)
}
}

View File

@@ -17,12 +17,17 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -32,23 +37,31 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
import kotlinx.datetime.LocalDate
/**
* Navigation drawer shared by every top-level calendar screen.
*
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
* header, the View switcher as a grouped card (the active view highlighted),
* the per-calendar visibility filter (M3) inline, and a pinned Settings row.
* The "View" section mirrors the top-bar switcher pill — tapping a view here
* selects it (and closes the drawer) rather than cycling. The host screen owns
* the drawer state.
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
* tapping a view here selects it (and closes the drawer) rather than cycling.
* The host screen owns the drawer state.
*
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
* anchor); [onJumpToDate] navigates the active view to the chosen day.
*/
@Composable
fun CalendarDrawer(
currentView: CalendarView,
currentDate: LocalDate,
onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit,
) {
var showDatePicker by remember { mutableStateOf(false) }
ModalDrawerSheet {
// The whole sidebar scrolls as one — header, views, the calendar filter
// and Settings all flow in a single scroll container.
@@ -71,6 +84,15 @@ fun CalendarDrawer(
)
}
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.drawer_jump_to_date),
position = Position.Alone,
minHeight = 56.dp,
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
onClick = { showDatePicker = true },
)
Spacer(Modifier.height(16.dp))
DrawerSectionHeader(stringResource(R.string.filter_title))
@@ -87,6 +109,17 @@ fun CalendarDrawer(
Spacer(Modifier.height(8.dp))
}
}
if (showDatePicker) {
CalendarDatePickerDialog(
initial = currentDate,
onConfirm = {
showDatePicker = false
onJumpToDate(it)
},
onDismiss = { showDatePicker = false },
)
}
}
/** Branded header: the app-icon chip beside the app name. */

View File

@@ -1,14 +1,56 @@
package de.jeanlucmakiola.calendula.ui.common
import android.provider.Settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
/**
* Whether the user has asked the system to remove animations (Settings →
* Accessibility → "Remove animations", which sets the global animator duration
* scale to 0). Compose animations do *not* honour this platform flag on their
* own, so the shared motion helpers in this file check it and fall back to a
* quick cross-fade — opacity only, no spatial movement — to respect the
* vestibular intent of the setting while keeping state changes legible.
*
* Read once at composition; the scale changes rarely and only takes full effect
* after a process restart anyway.
*/
@Composable
fun rememberReduceMotion(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember(resolver) {
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks.
* The fast effects spec from the active motion scheme, for opacity (fade)
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas and as the reduced-motion fallback.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.fastEffectsSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks/days.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the reduced-motion fade, typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
*/
fun calendarSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
reduceMotion: Boolean,
): ContentTransform {
if (reduceMotion) {
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
}
/**
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
* opacity, so it doubles as its own reduced-motion form.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarFadeThrough(): ContentTransform {
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
return fadeIn(fade).togetherWith(fadeOut(fade))
}
/**
* Enter transition for a vertically-revealed section (expandable rows, inline
* fields): height grows from the top while fading in. Under reduced motion the
* height growth is dropped, leaving a quick fade.
*
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
* originally inlined in the event edit form, so every expandable surface in the
* app reveals the same way.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/**
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
* motion keeps the fade only.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
}
}
/**
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
* app's motion scheme instead of Compose's default spring. Returns a bare
* [Modifier] under reduced motion so rows snap into place. Requires the list to
* supply stable item keys.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) {
Modifier
} else {
Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
)
}
/**
* Standard Android predictive-back transform for a full-screen overlay: as the
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
* swiped edge and rounds its corners, previewing what's behind. Completing the
* gesture invokes [onBack]; cancelling springs it back.
*
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
* once and apply the returned [Modifier] to that screen's root so the preview
* respects the same back semantics. Under reduced motion the visual preview is
* skipped (the back still works); on API < 34 the system delivers no progress,
* so it degrades to a plain back.
*/
@Composable
fun Modifier.predictiveBack(
onBack: () -> Unit,
enabled: Boolean = true,
reduceMotion: Boolean = rememberReduceMotion(),
): Modifier {
val progress = remember { Animatable(0f) }
var fromLeftEdge by remember { mutableStateOf(true) }
PredictiveBackHandler(enabled = enabled) { events ->
try {
events.collect { event ->
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
}
onBack()
progress.snapTo(0f)
} catch (_: CancellationException) {
progress.animateTo(0f)
}
}
if (reduceMotion) return this
return this.graphicsLayer {
val p = progress.value
val scale = 1f - 0.1f * p
scaleX = scale
scaleY = scale
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
shape = RoundedCornerShape(32.dp.toPx() * p)
clip = p > 0f
}
}

View File

@@ -5,17 +5,16 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarViewDay
import androidx.compose.material.icons.filled.CalendarViewMonth
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.ViewAgenda
import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R
/**
* The top-level calendar views the user can switch between (spec M1).
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
*/
/** The top-level calendar views the user can switch between (spec M1). */
enum class CalendarView {
Month,
Week,
Day,
Agenda,
}
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
@@ -25,6 +24,7 @@ val CalendarView.labelRes: Int
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
CalendarView.Agenda -> R.string.view_agenda
}
/** Leading icon for the view in the drawer's View section. */
@@ -33,6 +33,7 @@ val CalendarView.icon: ImageVector
CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.Day -> Icons.Filled.CalendarViewDay
CalendarView.Agenda -> Icons.Filled.ViewAgenda
}
/**
@@ -40,7 +41,7 @@ val CalendarView.icon: ImageVector
* through these in order.
*/
val IMPLEMENTED_VIEWS: List<CalendarView> =
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
/** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
@@ -48,3 +49,31 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
return available[(i + 1) % available.size]
}
/**
* The top-level view back stack (bottom → top): the [default] home view always
* sits at the bottom. Pressing back pops one level until only the home view
* remains, then the system exits the app. Three operations move it:
*
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
* history so back retraces it: switching to a not-yet-visited view pushes it on
* top; switching to one already in the stack pops back to it (collapsing the
* loop, so the depth stays bounded by the number of distinct views). Selecting
* the home view collapses to just `[default]`.
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
* the widget's view is the home view, else `[default, source]`. A widget entry
* starts a fresh context rather than extending in-app history.
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
* a widget), unless the day view is already current.
*/
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
val existing = indexOf(target)
return if (existing >= 0) take(existing + 1) else this + target
}
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
if (source == default) listOf(default) else listOf(default, source)
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day

View File

@@ -0,0 +1,82 @@
package de.jeanlucmakiola.calendula.ui.common
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.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* A wrapping row of round colour swatches; the one matching [selected] is
* ringed and checked. Shared by the calendar editor and the event-colour
* picker so both pick a colour the same way. Swatches render through
* [pastelize] — the softened colour the app actually paints, not the raw hue.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ColorSwatchRow(
colors: List<Int>,
selected: Int?,
onSelect: (Int) -> Unit,
dark: Boolean,
modifier: Modifier = Modifier,
) {
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
colors.forEach { argb ->
val isSelected = argb == selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(vertical = 4.dp)
.size(40.dp)
.clip(CircleShape)
.background(pastelize(argb, dark))
.then(
if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
)
.clickable { onSelect(argb) },
) {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.Black.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
0xFFD50000, // red
0xFFE67C00, // orange
0xFFF6BF26, // amber
0xFF33B679, // green
0xFF0B8043, // dark green
0xFF039BE5, // blue
0xFF3F51B5, // indigo
0xFF8E24AA, // purple
0xFF616161, // graphite
).map { it.toInt() }

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
* matches the card/grouped-row design language (not Material's outlined field).
*/
@Composable
fun DialogAmountField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
) {
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
// so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = value,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
},
placeholder = placeholder,
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
}
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
@Composable
fun DialogUnitDropdown(
label: String,
entries: List<String>,
onPick: (Int) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { open = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
) {
Text(text = label, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(4.dp))
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onPick(index)
open = false
},
)
}
}
}
}

View File

@@ -0,0 +1,44 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventFormField
/**
* The shared label and icon for each optional [EventFormField]. Both the event
* editor (which shows these fields) and the settings screen (which toggles their
* defaults) draw from this one mapping, so a field reads the same wherever it
* appears.
*/
@StringRes
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
}
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Location -> Icons.Default.Place
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette
EventFormField.Attendees -> Icons.Default.People
}

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -10,7 +9,9 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -71,11 +72,11 @@ fun CollapsingScaffold(
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(onBack = onBack)
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = modifier
.predictiveBack(onBack = onBack)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -102,7 +103,19 @@ fun CollapsingScaffold(
Column(
modifier = Modifier
.padding(innerPadding)
// Mark the scaffold's system-bar insets as consumed so the
// imePadding below adds only the keyboard height beyond them
// (max, not sum) — otherwise the nav-bar inset double-counts and
// leaves an empty strip above the keyboard.
.consumeWindowInsets(innerPadding)
.fillMaxSize()
// Paint the surface across the full area before imePadding carves
// into it, so any sliver above the keyboard reads as surface — not
// the dialog window's black — during the IME animation.
.background(MaterialTheme.colorScheme.surface)
// Shrink the scroll viewport by the keyboard inset so a focused
// field (e.g. the custom-reminder amount) can scroll into view.
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = 24.dp),
content = content,
@@ -114,7 +127,9 @@ fun CollapsingScaffold(
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
* corner radii come from its [position] (so a run of rows reads as a single
* rounded card). Corners round further on press. A null [onClick] makes the
* row non-interactive (e.g. read-only entries).
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
* summary to the M3 disabled emphasis while leaving the [trailing] control at
* full opacity — for rows that are present but switched off.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -124,6 +139,8 @@ fun GroupedRow(
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
@@ -155,6 +172,16 @@ fun GroupedRow(
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else if (dimmed) {
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
// stays full-opacity so the toggle reads as live even on a faded row.
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = muted,
leadingIconColor = muted,
supportingColor = muted,
)
} else {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
@@ -172,10 +199,10 @@ fun GroupedRow(
.fillMaxWidth()
.padding(horizontal = 16.dp)
.then(gap)
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
val containerColor = when {
selected -> MaterialTheme.colorScheme.secondaryContainer
container != null -> container
else -> MaterialTheme.colorScheme.surfaceContainerHigh
}
if (onClick != null) {
Surface(

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
/**
* Whether the week/day timeline draws an hour separator line, from the
* `showHourLines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical clean look.
*/
val LocalShowHourLines = staticCompositionLocalOf { false }
/**
* Draw a faint separator line at the top of each hour (1..23) when [show] is
* true. Applied to a day column's content so each line sits over the column's
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
for (hour in 1 until 24) {
val y = hour * hourHeightPx
drawLine(
color = color,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
}
}
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@@ -36,6 +38,9 @@ fun InlineTextField(
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
imeAction: ImeAction = ImeAction.Default,
/** Invoked when the IME action key (e.g. Done) is pressed. */
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
@@ -53,7 +58,11 @@ fun InlineTextField(
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
imeAction = imeAction,
),
keyboardActions = onImeAction?.let { action ->
KeyboardActions(onAny = { action() })
} ?: KeyboardActions.Default,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {

View File

@@ -0,0 +1,90 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
private val DotSize = 8.dp
private val LineThickness = 2.dp
/**
* Current wall-clock instant that updates once a minute, re-aligning to each
* minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
* the middle of a minute. Drives the "now" line in the day/week grids.
*/
@Composable
fun rememberCurrentMinute(): State<Instant> {
val instant = remember { mutableStateOf(Clock.System.now()) }
LaunchedEffect(Unit) {
while (true) {
val now = Clock.System.now()
instant.value = now
delay(60_000L - now.toEpochMilliseconds() % 60_000L)
}
}
return instant
}
/**
* A thin "current time" indicator — a leading dot plus a line — drawn across a
* day column. Positioned on the same [hourHeight] scale the event blocks use so
* it lines up with the grid, and refreshed each minute. Renders nothing unless
* the wall clock is on [date]; callers mount it only for the column showing
* today, so the per-minute tick runs on a single column.
*/
@Composable
fun NowLine(
date: LocalDate,
hourHeight: Dp,
modifier: Modifier = Modifier,
) {
val now by rememberCurrentMinute()
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
if (local.date != date) return
val minutes = local.hour * 60 + local.minute
val top = hourHeight * (minutes / 60f)
val color = MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.fillMaxWidth()
.height(DotSize)
.offset(y = top - DotSize / 2),
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(LineThickness)
.background(color),
)
// The dot anchors the line to the gutter edge, mirroring the standard
// calendar "now" marker; drawn after the line so it sits on top.
Box(
modifier = Modifier
.size(DotSize)
.background(color, CircleShape),
)
}
}

View File

@@ -0,0 +1,16 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.compositionLocalOf
import kotlin.time.Instant
/**
* The cut-off instant before which a calendar event counts as "completed" and is
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
* the "dim completed events" setting is on, or `null` when it is off (nothing
* dims). Provided per grid screen so only the event chips that read it recompose
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
*/
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f

View File

@@ -0,0 +1,445 @@
package de.jeanlucmakiola.calendula.ui.common
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.fillMaxWidth
import androidx.compose.foundation.layout.height
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.filled.Check
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
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 android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.LocalView
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
/**
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
* picker is visually identical to a Settings sub-page and uses the full width.
* [content] places the connected grouped rows; selecting one calls [onDismiss].
*/
@Composable
fun FullScreenPicker(
title: String,
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
// The dialog window pans by default when the keyboard opens, which —
// combined with the content's own imePadding — leaves a fixed black gap
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
// full-screen and imePadding alone lifts the focused field.
val view = LocalView.current
SideEffect {
(view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
}
}
/**
* General single-select picker, full-screen: each option is a connected grouped
* row and the current one carries a check. Drop-in for the former dialog
* (theme, week start, language, …).
*/
@Composable
fun <T> OptionPicker(
title: String,
options: List<T>,
selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
header: (@Composable ColumnScope.() -> Unit)? = null,
) {
FullScreenPicker(title = title, onDismiss = onDismiss) {
header?.invoke(this)
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = label(option),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
}
}
/**
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
* a "Custom" row that expands an inline number field plus a segmented unit
* selector. Returns the choice as a [CalendarReminderOverride].
*/
@Composable
fun ReminderDefaultPicker(
title: String,
presets: List<Int>,
selected: CalendarReminderOverride,
allowInherit: Boolean,
onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit,
) {
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) }
var unit by rememberSaveable { mutableStateOf(seed.second) }
val options = buildList {
if (allowInherit) add(CalendarReminderOverride.Inherit)
add(CalendarReminderOverride.None)
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
}
val rowCount = options.size + 1 // + the custom row
FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = reminderOverrideLabel(option),
position = positionOf(index, rowCount),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
// When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern).
GroupedRow(
title = if (customSelected) {
stringResource(
R.string.reminder_custom_with_value,
reminderLeadTimeLabel(selectedMinutes!!),
)
} else {
stringResource(R.string.event_edit_reminder_custom)
},
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
onConfirm = { minutes ->
onSelect(CalendarReminderOverride.Minutes(minutes))
onDismiss()
},
)
}
}
}
/**
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
* row above it (matching the grouped-row system, so the two read as one
* container). An amount field with a live preview of the resulting lead time, a
* single-choice unit toggle, and a tonal confirm enabled only for a valid
* 1999 amount. [onConfirm] receives the final lead time in minutes.
*/
@Composable
private fun CustomReminderEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
onConfirm: (Int) -> Unit,
) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 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. */
@Composable
private fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@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
* options (today / this week / this month) and the rolling windows (next 7 / 30
* days), with a "Custom" row that expands an inline day-count editor (1365).
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
*/
@Composable
fun AgendaRangePicker(
title: String,
description: String,
selected: AgendaRange,
onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit,
) {
val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
val rollingRowCount = rolling.size + 1 // + the custom row
val customSelected = selected is AgendaRange.Custom
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable {
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow(
title = agendaRangeLabel(option),
position = position,
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
PickerDescription(description)
// Calendar-aligned windows.
calendarAligned.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, calendarAligned.size))
}
Spacer(Modifier.height(12.dp))
// Rolling windows + custom.
rolling.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, rollingRowCount))
}
GroupedRow(
title = if (customSelected) {
agendaRangeLabel(selected)
} else {
stringResource(R.string.agenda_range_custom)
},
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
CustomDaysEditor(
amountText = amountText,
onAmountChange = { amountText = it },
onConfirm = { days ->
onSelect(AgendaRange.Custom(days))
onDismiss()
},
)
}
}
}
/** The expanded "Custom" day-count editor: an amount field (1365) and Set. */
@Composable
private fun CustomDaysEditor(
amountText: String,
onAmountChange: (String) -> Unit,
onConfirm: (Int) -> Unit,
) {
val days = amountText.toIntOrNull()
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "30",
)
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))
}
}
}
}
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
@Composable
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
AgendaRange.Day -> stringResource(R.string.agenda_range_day)
AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week)
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
AgendaRange.Week -> stringResource(R.string.agenda_range_week)
AgendaRange.Month -> stringResource(R.string.agenda_range_month)
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
}
@Composable
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
}
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
minutes == null -> "" to ReminderUnit.Minutes
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
else -> minutes.toString() to ReminderUnit.Minutes
}

View File

@@ -0,0 +1,46 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/** Common reminder lead times offered as quick picks in the form and settings. */
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
enum class ReminderUnit(val minutesFactor: Int) {
Minutes(1),
Hours(60),
Days(1_440),
Weeks(10_080),
}
@StringRes
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
ReminderUnit.Hours -> R.string.reminder_unit_hours
ReminderUnit.Days -> R.string.reminder_unit_days
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
}
/**
* Humanise a reminder lead time (minutes before the event start) into one
* line: "Default reminder" (negative = the provider default), "At time of
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
* screen, the event form and the default-reminder settings so the wording
* never drifts.
*/
@Composable
fun reminderLeadTimeLabel(minutes: Int): String = when {
minutes < 0 -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time)
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
minutes % 60 == 0 ->
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}

View File

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
* `false` = 12-hour ("2:00 PM"). Provided once at the app root from the
* `TimeFormatPref` preference resolved against the device's 24-hour system
* setting, so every time label reads it without per-screen plumbing. Defaults
* to 24-hour for previews and any composition that forgets to provide it.
*/
val LocalUse24HourFormat = staticCompositionLocalOf { true }
private const val PATTERN_24 = "HH:mm"
private const val PATTERN_12 = "h:mm a"
private const val HOUR_PATTERN_12 = "h a"
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
DateTimeFormatter.ofPattern(if (is24Hour) PATTERN_24 else PATTERN_12, locale)
/**
* Format a wall-clock time (hour 0..23, minute 0..59) honouring the resolved
* [is24Hour] convention and [locale]: 24h → "14:00", 12h → "2:00 PM". Pure, so
* it can be unit-tested and used off the main thread (widget, notifications).
*/
fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale): String =
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
.format(timeOfDayFormatter(is24Hour, locale))
/**
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
* ("12:00 AM") in 12h, which has no 24:00 equivalent.
*/
fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String = when {
minutes >= MINUTES_PER_DAY && is24Hour -> "24:00"
minutes >= MINUTES_PER_DAY -> formatTimeOfDay(0, 0, is24Hour = false, locale)
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
}
/**
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
* the prior look); 12h → "1 PM".
*/
fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
if (is24Hour) {
"%02d".format(hour)
} else {
LocalTime.of(hour.coerceIn(0, 23), 0)
.format(DateTimeFormatter.ofPattern(HOUR_PATTERN_12, locale))
}
private const val MINUTES_PER_DAY = 1_440

View File

@@ -0,0 +1,68 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.content.Context
import android.content.res.Resources
import android.provider.Settings
import android.text.format.DateFormat
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalTime
/**
* M3 time picker in an alert dialog, seeded with [initial]. Shared by the event
* form (start/end times) and Settings (the all-day reminder fire time).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePickerAlert(
initial: LocalTime,
onConfirm: (LocalTime) -> Unit,
onDismiss: () -> Unit,
) {
val state = rememberTimePickerState(
initialHour = initial.hour,
initialMinute = initial.minute,
is24Hour = deviceUses24HourClock(LocalContext.current),
)
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
Text(stringResource(R.string.dialog_ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
text = { TimePicker(state = state) },
)
}
/**
* Whether the clock should read 24-hour, matching the rest of the device.
*
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
* the *app's* context locale — and this app applies a per-app language
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
* system 12/24 override, and otherwise fall back to the **device** locale
* (Resources.getSystem), not the app's.
*/
private fun deviceUses24HourClock(context: Context): Boolean =
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
"24" -> true
"12" -> false
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
else -> {
val deviceLocale = Resources.getSystem().configuration.locales[0]
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
}
}

View File

@@ -0,0 +1,59 @@
package de.jeanlucmakiola.calendula.ui.crash
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
/**
* A deliberately minimal, standalone surface for a captured crash report.
* `MainActivity` routes here when it detects a startup crash-loop (see
* [CrashReporter.isCrashLoop]): the main UI can't be trusted to start, so this
* screen stays clear of the app's Hilt graph, DataStore-backed theme and
* Compose content — it only reads the report file and shows the report dialog.
* Plain [CalendulaTheme] defaults (follow-system, dynamic colour) avoid touching
* anything that might be the cause of the crash.
*/
class CrashReportActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val report = CrashReporter.pendingReport(this)
if (report == null) {
finish()
return
}
enableEdgeToEdge()
setContent {
CalendulaTheme {
// Opaque backdrop so the dialog doesn't float over a bare task.
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this, report)
CrashReporter.clearReport(this)
finish()
},
onDismiss = {
CrashReporter.clearReport(this)
finish()
},
)
}
}
}
override fun onResume() {
super.onResume()
// Reaching this screen breaks the loop; reset the timing trail so a
// later ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
}

View File

@@ -0,0 +1,75 @@
package de.jeanlucmakiola.calendula.ui.crash
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* Asks the user to send a captured crash report as an issue. The full report is
* shown verbatim in a scrollable panel — the user sees exactly what will leave
* the device before choosing to share it (the privacy backstop). [onSend] hands
* off to [submitCrashReport]; [onDismiss] declines.
*/
@Composable
fun CrashReportDialog(
report: String,
onSend: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
title = { Text(stringResource(R.string.crash_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.crash_dialog_message),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = report,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.heightIn(max = 220.dp)
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
},
confirmButton = {
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
},
)
}

View File

@@ -0,0 +1,61 @@
package de.jeanlucmakiola.calendula.ui.crash
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.net.toUri
import de.jeanlucmakiola.calendula.R
/**
* Hand the captured crash report off to the user's chosen channel: the report
* is copied to the clipboard (the reliable path for a full stack trace) and the
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
* sent automatically — the app has no network access; the user reviews and
* submits the issue themselves.
*/
fun submitCrashReport(context: Context, report: String) {
copyReportToClipboard(context, report)
val opened = runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
}.isSuccess
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
fun openIssueTracker(context: Context) {
val uri = context.getString(R.string.report_issue_choose_url).toUri()
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
}
private fun copyReportToClipboard(context: Context, report: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
val label = context.getString(R.string.crash_report_clip_label)
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
}
/**
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
* can blow past URL-length limits, so an over-long one is left out of the link
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
* source of truth in that case.
*/
private fun buildIssueUri(context: Context, report: String) =
context.getString(R.string.report_issue_url).toUri().buildUpon()
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
.appendQueryParameter("body", buildIssueBody(context, report))
.build()
private fun buildIssueBody(context: Context, report: String): String {
val block = if (report.length > MAX_URL_REPORT_CHARS) {
context.getString(R.string.crash_report_body_paste)
} else {
"```\n$report\n```"
}
return context.getString(R.string.crash_report_body_template, block)
}
/** Keep the prefilled body comfortably under common URL-length ceilings. */
private const val MAX_URL_REPORT_CHARS = 6_000

View File

@@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -71,12 +72,20 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -105,6 +114,7 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
@@ -151,6 +161,11 @@ fun DayScreen(
}
viewModel.goToToday()
}
// Drawer jump-to-date: slide from the side the target lies on.
val jumpToDate: (LocalDate) -> Unit = { target ->
slideDir = if (target < date) -1 else 1
viewModel.goToDate(target)
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -159,10 +174,15 @@ fun DayScreen(
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = date,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
@@ -178,6 +198,7 @@ fun DayScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -223,6 +244,8 @@ private fun DayContent(
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
@@ -273,7 +296,7 @@ private fun DayContent(
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
@@ -330,6 +353,7 @@ private fun DayTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -348,6 +372,12 @@ private fun DayTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -441,6 +471,8 @@ private fun Timeline(
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day column are two scroll viewports that SHARE one scroll
@@ -463,7 +495,7 @@ private fun Timeline(
) {
if (h > 0) {
Text(
text = "%02d".format(h),
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
@@ -486,6 +518,7 @@ private fun Timeline(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -502,11 +535,14 @@ private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -519,6 +555,9 @@ private fun DayColumnCard(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour.
@@ -546,6 +585,10 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
}
}
}
}
@@ -558,7 +601,10 @@ private fun EventBlock(
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}"
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45
Box(
modifier = modifier
@@ -604,8 +650,8 @@ private fun DayLoading() {
}
}
private fun minToHm(min: Int): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate): String {
val locale = Locale.getDefault()

View File

@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -41,6 +40,7 @@ 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.Schedule
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -59,6 +59,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -67,7 +68,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
@@ -91,11 +91,16 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -129,12 +134,31 @@ fun EventDetailScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
// an .ics in the cache and hands back a content Uri for the chooser.
val shareFailedMessage = stringResource(R.string.event_share_failed)
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
val onShareClick = {
scope.launch {
val uri = viewModel.shareUri()
val sent = uri != null && runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, shareChooserTitle))
}.isSuccess
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
}
Unit
}
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action.
var pendingEdit by remember { mutableStateOf(false) }
@@ -190,6 +214,7 @@ fun EventDetailScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -203,9 +228,18 @@ fun EventDetailScreen(
}
},
actions = {
// Only writable calendars get actions — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level.
val s = state
// Share works for any loaded event — it only reads the event.
if (s is EventDetailUiState.Success) {
IconButton(onClick = onShareClick) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(R.string.event_detail_share),
)
}
}
// Edit/delete need a writable calendar — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level.
if (s is EventDetailUiState.Success && s.canModify) {
IconButton(
onClick = onEditClick,
@@ -557,10 +591,22 @@ private fun AttendeeRow(attendee: Attendee) {
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
val hasName = attendee.name.isNotBlank()
Text(
text = attendee.name.ifBlank { attendee.email.orEmpty() },
text = if (hasName) attendee.name else attendee.email.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
)
// Email as supporting text — only when the name is the headline, so we
// don't repeat it on rows that already fall back to the email above.
if (hasName) {
attendee.email?.takeIf { it.isNotBlank() }?.let { email ->
Text(
text = email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
attendeeRoleLabel(attendee)?.let { roleRes ->
Text(
text = stringResource(roleRes),
@@ -684,26 +730,7 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
@Composable
private fun reminderLeadText(reminder: Reminder): String {
val minutes = reminder.minutes
return when {
minutes < 0 -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time)
minutes % 10_080 == 0 -> {
val weeks = minutes / 10_080
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
}
minutes % 1_440 == 0 -> {
val days = minutes / 1_440
pluralStringResource(R.plurals.reminder_days, days, days)
}
minutes % 60 == 0 -> {
val hours = minutes / 60
pluralStringResource(R.plurals.reminder_hours, hours, hours)
}
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}
}
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/**
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
@@ -756,20 +783,25 @@ private fun formatWhen(
val zid = ZoneId.of(zone.id)
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
val startLdt = instance.start.toJavaLocalDateTime(zid)
val allDayLabel = stringResource(R.string.event_detail_all_day)
if (instance.isAllDay) {
// All-day end is the exclusive next midnight; step back to the last
// covered day so a one-day event reads as a single date.
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
allDayLabel to dateFull.format(startLdt.toLocalDate())
// All-day events live at UTC midnights with an exclusive end. Resolve
// the covered dates in UTC — not the device zone, which would shift the
// midnight boundaries off the intended date (east of UTC pushes the
// end past the last day; west of UTC pulls the start back) — and step
// the end back to the last covered day so a one-day event reads as a
// single date.
val utc = ZoneId.of("UTC")
val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
return if (startDate == lastDate) {
allDayLabel to dateFull.format(startDate)
} else {
allDayLabel to
"${dateMedium.format(startLdt.toLocalDate())} ${dateMedium.format(lastLdt.toLocalDate())}"
allDayLabel to "${dateMedium.format(startDate)} ${dateMedium.format(lastDate)}"
}
}

View File

@@ -1,14 +1,20 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -34,6 +40,7 @@ import javax.inject.Inject
@HiltViewModel
class EventDetailViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -113,6 +120,24 @@ class EventDetailViewModel @Inject constructor(
_deleteState.value = DeleteUiState.Idle
}
/**
* Serialise the open event to a `.ics` cache file and return a shareable
* content Uri (for an ACTION_SEND), or null when nothing is loaded or the
* write fails. The event is exported as a one-off (see [toShareIcsEvent]).
*/
suspend fun shareUri(): Uri? {
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
return runCatching {
withContext(io) {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
}
}.getOrNull()
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
@@ -143,3 +168,13 @@ class EventDetailViewModel @Inject constructor(
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.edit
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
@@ -28,11 +29,29 @@ data class EventEditUiState(
val hiddenFields: List<EventFormField> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false,
/**
* Whether to focus the title and raise the keyboard when this is a fresh
* create form (issue #10). Mirrors the settings flag; the screen only acts
* on it for a new event with an empty title, never when editing/importing.
*/
val autofocusTitle: Boolean = true,
/**
* True while an edit changed the recurrence rule — the save-scope dialog
* then drops "only this event" (an exception row can't carry a rule).
*/
val recurrenceChanged: Boolean = false,
/**
* The event-colour palette the resolved target calendar publishes; empty
* when it exposes none. Non-empty → the colour picker offers these swatches
* (written as a key, sync-safe); empty → see [colorMode].
*/
val colorPalette: List<EventColorOption> = emptyList(),
/**
* Whether the user has opted into custom colours on calendars that publish
* no palette (a synced one may then drop the colour on sync). Mirrors the
* settings flag; ignored for local and palette-backed calendars.
*/
val allowColorOnUnsupportedCalendars: Boolean = false,
)
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */

View File

@@ -8,10 +8,13 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
@@ -19,12 +22,18 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -65,6 +74,10 @@ class EventEditViewModel @Inject constructor(
// Set while the form edits an existing event instead of composing a new one.
private val _editTarget = MutableStateFlow<EditTarget?>(null)
private val _loadFailed = MutableStateFlow(false)
// True once the user has hand-edited the reminders on a new event, which
// freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false)
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
@@ -94,23 +107,65 @@ class EventEditViewModel @Inject constructor(
val editTarget: EditTarget?,
)
private data class ReminderDefaults(
val timed: Int?,
val allDay: Int?,
val timedOverrides: Map<Long, Int?>,
val allDayOverrides: Map<Long, Int?>,
)
private data class ExternalInputs(
val writable: List<CalendarSource>,
val lastUsed: Long?,
val defaultFields: Set<EventFormField>,
val allowColorOnUnsupported: Boolean,
val autofocusTitle: Boolean,
)
/**
* Writable calendars — the only valid event targets. Disabled calendars are
* excluded, so you can't create into a calendar you've removed from the app;
* a last-used preselect landing on a now-disabled calendar falls back to the
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
*/
private val writableCalendars: Flow<List<CalendarSource>> = combine(
repository.calendars(),
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled }
}.catch { emit(emptyList()) }
/** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine(
_form.map { it?.calendarId },
writableCalendars,
prefs.lastUsedCalendarId,
) { picked, writable, lastUsed ->
picked
?: lastUsed?.takeIf { id -> writable.any { it.id == id } }
?: writable.firstOrNull()?.id
}.distinctUntilChanged()
/** The resolved calendar's published event palette, refetched when it changes. */
@OptIn(ExperimentalCoroutinesApi::class)
private val colorPalette: Flow<List<EventColorOption>> = resolvedCalendarId
.flatMapLatest { id ->
flow { emit(id?.let { repository.eventColorPalette(it) }.orEmpty()) }
}
.flowOn(io)
val state: StateFlow<EventEditUiState?> = combine(
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
combine(
repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) },
writableCalendars,
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
settingsPrefs.allowColorOnUnsupportedCalendars,
settingsPrefs.autofocusEventTitle,
::ExternalInputs,
).flowOn(io),
) { local, external ->
colorPalette,
) { local, external, palette ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
@@ -125,10 +180,13 @@ class EventEditViewModel @Inject constructor(
visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null,
autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change.
recurrenceChanged = local.editTarget != null &&
resolved.rrule != local.editTarget.original.rrule,
colorPalette = palette,
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
)
}
.stateIn(
@@ -161,6 +219,63 @@ class EventEditViewModel @Inject constructor(
}
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
applyDefaultReminder()
}
/**
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
* the create form" path). [form] already carries the file's fields; its
* [EventForm.calendarId] is null so the calendar still resolves to the
* last-used/first-writable one, and reminders are frozen as touched so the
* settings default never overwrites what the file specified. No-op when a
* form is already open, so the prefill survives configuration changes.
*/
fun openImported(form: EventForm) {
if (_form.value != null || _editTarget.value != null) return
_remindersTouched.value = true
_revealed.value = form.populatedFields()
_form.value = form
}
/**
* Prefill a new event's reminders from the settings default — the all-day
* default for all-day events, otherwise the resolved calendar's per-calendar
* override or the global timed default. No-op while editing an existing event
* or once the user has hand-edited the reminders, so the auto-default never
* clobbers a manual choice. [calendarId] short-circuits the resolution after a
* calendar switch; null resolves it as the form does.
*/
private fun applyDefaultReminder(calendarId: Long? = null) {
if (_editTarget.value != null || _remindersTouched.value) return
viewModelScope.launch {
val defaults = combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
val targetId = calendarId ?: resolvedCalendarId.first()
// Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val default = resolveDefaultReminder(
timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides,
allDayOverrides = defaults.allDayOverrides,
calendarId = targetId,
isAllDay = form.isAllDay,
)
val reminders = listOfNotNull(default)
_form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty()) {
_revealed.value = _revealed.value + EventFormField.Reminders
}
}
}
/**
@@ -196,6 +311,7 @@ class EventEditViewModel @Inject constructor(
_revealed.value = emptySet()
_editTarget.value = null
_loadFailed.value = false
_remindersTouched.value = false
}
/** Unfold one optional field, picked in the "more fields" dialog. */
@@ -206,20 +322,74 @@ class EventEditViewModel @Inject constructor(
fun setTitle(value: String) = update { it.copy(title = value) }
fun setLocation(value: String) = update { it.copy(location = value) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
fun setCalendar(id: Long) = update { it.copy(calendarId = id) }
fun setAllDay(value: Boolean) {
update { it.copy(isAllDay = value) }
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder()
}
/**
* Switching calendars drops any chosen colour: a palette key is
* account-scoped, and a raw colour may be invalid on the new calendar.
* The event falls back to the new calendar's colour until re-picked.
*/
fun setCalendar(id: Long) {
update { it.copy(calendarId = id, colorKey = null, color = null) }
// A fresh event re-inherits the new calendar's default reminder unless
// the user has already hand-edited it (guarded inside).
applyDefaultReminder(id)
}
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
/** Clear the colour so the event inherits its calendar's. */
fun clearColor() = update { it.copy(colorKey = null, color = null) }
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
fun addReminder(minutes: Int) = update {
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
fun addReminder(minutes: Int) {
_remindersTouched.value = true
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
}
fun removeReminder(minutes: Int) = update {
it.copy(reminders = it.reminders - minutes)
fun removeReminder(minutes: Int) {
_remindersTouched.value = true
update { it.copy(reminders = it.reminders - minutes) }
}
/**
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
* address just refreshes it (no duplicate row). A blank email is ignored —
* the email is what we write and dedup on.
*/
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
val trimmed = email.trim()
if (trimmed.isEmpty()) return
update { form ->
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
form.copy(attendees = rest + attendee)
}
}
fun removeAttendee(attendee: EventAttendee) =
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
/** Flip a guest between required and optional (the per-row role toggle). */
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
form.copy(
attendees = form.attendees.map { a ->
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
},
)
}
/** Moving the start drags the end along, preserving the duration. */

View File

@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
combine(
repository.calendars(),
prefs.hiddenCalendarIds,
) { calendars, hidden ->
if (calendars.isEmpty()) {
prefs.disabledCalendarIds,
) { calendars, hidden, disabled ->
// Disabled calendars are gone from the app entirely — they don't
// belong in the drawer's hide/show list (you can't hide what's
// already disabled). They live only in Settings → Calendars.
val enabled = calendars.filterNot { it.id in disabled }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else {
FilterUiState.Success(groupByAccount(calendars, hidden))
FilterUiState.Success(groupByAccount(enabled, hidden))
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }

View File

@@ -0,0 +1,208 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard
/**
* Handles an opened/received `.ics` file. A single event is handed straight to
* the prefilled create form via [onOpenSingle]; several events show a target-
* calendar picker and import in bulk (dedup by UID), then a result summary.
* Empty/failed files show a short message and close.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImportScreen(
uri: Uri,
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
viewModel: ImportViewModel = hiltViewModel(),
) {
LaunchedEffect(uri) { viewModel.load(uri) }
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
LaunchedEffect(state) {
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
}
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.import_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
when (val s = state) {
ImportUiState.Loading,
ImportUiState.Importing,
is ImportUiState.Single,
-> CircularProgressIndicator(Modifier.align(Alignment.Center))
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
is ImportUiState.Done -> DoneContent(s, onClose)
}
}
}
}
@Composable
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
// No writable calendar to import into — tell the user honestly.
if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
return
}
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp),
)
Text(
stringResource(R.string.import_target_header),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
state.calendars.forEach { calendar ->
OptionCard(
label = calendar.displayName,
onClick = { selected = calendar.id },
selected = calendar.id == selected,
icon = null,
)
}
state.warnings.forEach { WarningText(it) }
Button(
onClick = { onImport(selected) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
}
}
}
@Composable
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
stringResource(R.string.import_done_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 24.dp),
)
Text(
pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
style = MaterialTheme.typography.bodyLarge,
)
if (state.summary.skippedDuplicate > 0) {
Text(
pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
Text(stringResource(R.string.import_close))
}
}
}
@Composable
private fun WarningText(warning: IcsParseWarning) {
val text = when (warning) {
IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message, style = MaterialTheme.typography.bodyLarge)
if (onClose != null) {
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
}
}
}
}

View File

@@ -0,0 +1,113 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/** What an opened/received `.ics` resolved to. */
sealed interface ImportUiState {
data object Loading : ImportUiState
data object Importing : ImportUiState
/** The file held no importable event (or couldn't be read/parsed as one). */
data object Empty : ImportUiState
data object Failed : ImportUiState
/** Exactly one event → review it in the prefilled create form. */
data class Single(val form: EventForm, val warnings: Set<IcsParseWarning>) : ImportUiState
/** Several events → pick a target calendar and bulk-import. */
data class Many(
val events: List<ParsedIcsEvent>,
val warnings: Set<IcsParseWarning>,
val calendars: List<CalendarSource>,
) : ImportUiState
data class Done(val summary: IcsImportSummary) : ImportUiState
}
/**
* Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
* one event opens the create form for review, many open the bulk-import picker.
* The bulk import dedups by UID in the repository.
*/
@HiltViewModel
class ImportViewModel @Inject constructor(
private val repository: CalendarRepository,
private val importer: IcsImporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val parser = IcsParser()
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
fun load(uri: Uri) {
if (started) return
started = true
viewModelScope.launch {
val parsed = withContext(io) {
importer.readText(uri)?.let(parser::parse)
}
_state.value = when {
parsed == null -> ImportUiState.Failed
parsed.events.isEmpty() -> ImportUiState.Empty
parsed.events.size == 1 -> ImportUiState.Single(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings,
)
else -> {
// A disabled calendar is removed from the app, so it can't be
// an import target — exclude it alongside the read-only ones.
val disabled = prefs.disabledCalendarIds.first()
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.canModifyContents && it.id !in disabled },
)
}
}
}
}
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
fun import(targetCalendarId: Long) {
val many = _state.value as? ImportUiState.Many ?: return
viewModelScope.launch {
_state.value = ImportUiState.Importing
_state.value = try {
ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
ImportUiState.Failed
}
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -35,6 +36,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@@ -43,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
@@ -59,12 +63,18 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -86,6 +96,7 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
@@ -93,6 +104,14 @@ fun MonthScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
val nowState = rememberCurrentMinute()
val dimCutoff by remember(dimCompleted) {
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -124,6 +143,11 @@ fun MonthScreen(
}
viewModel.goToToday()
}
// Drawer jump-to-date: slide from the side the target month lies on.
val jumpToDate: (LocalDate) -> Unit = { target ->
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
viewModel.goToDate(target)
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -132,10 +156,15 @@ fun MonthScreen(
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = LocalDate(month.year, month.month, 1),
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
@@ -151,6 +180,7 @@ fun MonthScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -178,14 +208,16 @@ fun MonthScreen(
.fillMaxSize(),
) {
WeekdayHeader(weekStart = weekStart)
MonthContent(
state = state,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
MonthContent(
state = state,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
)
}
}
}
}
@@ -204,6 +236,8 @@ private fun MonthContent(
val threshold = with(density) { 6.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
@@ -230,7 +264,7 @@ private fun MonthContent(
MonthUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "month-transition",
) { s ->
when (s) {
@@ -251,6 +285,7 @@ private fun MonthTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -269,6 +304,12 @@ private fun MonthTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -534,6 +575,8 @@ private fun MonthBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -541,7 +584,7 @@ private fun MonthBar(
bottomEnd = if (continuesRight) 0.dp else 4.dp,
)
Box(
modifier = modifier
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), shape)
.padding(horizontal = 4.dp)
.semantics { contentDescription = title },

View File

@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
initialValue = DayOfWeek.MONDAY,
)
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
@@ -96,6 +104,11 @@ class MonthViewModel @Inject constructor(
_month.value = YearMonth(todayDate.year, todayDate.month)
}
/** Jump to the month containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_month.value = YearMonth(date.year, date.month)
}
private fun buildState(
ym: YearMonth,
weekStart: DayOfWeek,
@@ -108,53 +121,57 @@ class MonthViewModel @Inject constructor(
return MonthUiState.Success(
month = ym,
today = todayDate,
weeks = layoutMonth(ym, weekStart, instances),
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
)
}
}
/**
* Split the grid into week rows and resolve each row's events. An event is a
* spanning bar when it's all-day or touches more than one of the row's days;
* everything else is a single-day timed pill. Bars get lanes from the shared
* [layoutAllDay] so a multi-day event stays on one row across the week.
*/
private fun layoutMonth(
ym: YearMonth,
weekStart: DayOfWeek,
instances: List<EventInstance>,
): List<MonthWeek> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
val weekCount = (leadOffset + daysInMonth + 6) / 7
/**
* Split the month grid into week rows and resolve each row's events. An event is
* a spanning bar when it's all-day or touches more than one of the row's days;
* everything else is a single-day timed pill. Bars get lanes from the shared
* [layoutAllDay] so a multi-day event stays on one row across the week.
*
* Shared by the Month screen and the month home-screen widget so both lay out
* spans, lanes and per-day counts identically.
*/
internal fun layoutMonthWeeks(
ym: YearMonth,
weekStart: DayOfWeek,
instances: List<EventInstance>,
zone: TimeZone,
): List<MonthWeek> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
val weekCount = (leadOffset + daysInMonth + 6) / 7
return (0 until weekCount).map { row ->
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
val (bars, singles) = weekEvents.partition { ev ->
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(
event = s.event,
startCol = s.startCol,
endCol = s.endCol,
lane = s.lane,
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
)
}
MonthWeek(
days = days,
spans = spans,
timedByDay = days.associateWith { d ->
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
},
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
return (0 until weekCount).map { row ->
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
val (bars, singles) = weekEvents.partition { ev ->
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(
event = s.event,
startCol = s.startCol,
endCol = s.endCol,
lane = s.lane,
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
)
}
MonthWeek(
days = days,
spans = spans,
timedByDay = days.associateWith { d ->
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
},
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
)
}
}

View File

@@ -0,0 +1,260 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.Instant as JavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Full-text event search (top-bar entry). Type a query → matching events
* (title / location / description) across the whole calendar, newest-relevant
* first; tap a result to open its detail. A full-screen overlay hosted by
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
onBack: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
// Each fresh open starts blank and straight into typing. The ViewModel is
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
// resets a previous search. Peeking a result doesn't re-run this (the screen
// stays composed under the detail), so backing out keeps the query.
LaunchedEffect(Unit) {
viewModel.setQuery("")
focusRequester.requestFocus()
keyboard?.show()
}
Scaffold(
modifier = modifier.predictiveBack(onBack = onBack),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
title = {
InlineTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = stringResource(R.string.search_hint),
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.search_back),
)
}
},
actions = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_clear),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { padding ->
// imePadding shrinks the content by the keyboard, so the centered
// idle/empty message re-centres in the space above it (and the results
// list lifts clear of the keyboard too).
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
when (val s = state) {
SearchUiState.Idle -> SearchMessage(
icon = null,
text = stringResource(R.string.search_idle_hint),
)
is SearchUiState.Empty -> SearchMessage(
icon = Icons.Default.SearchOff,
text = stringResource(R.string.search_empty, s.query),
)
is SearchUiState.Results -> SearchResults(
events = s.events,
onEventClick = onEventClick,
)
}
}
}
}
@Composable
private fun SearchResults(
events: List<EventInstance>,
onEventClick: (EventInstance) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
) {
itemsIndexed(
items = events,
key = { _, event -> event.eventId },
) { index, event ->
SearchResultRow(
event = event,
position = positionOf(index, events.size),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
}
}
}
@Composable
private fun SearchResultRow(
event: EventInstance,
position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
GroupedRow(
modifier = modifier,
title = event.title,
summary = searchSummary(event),
position = position,
minHeight = 64.dp,
leading = {
Box(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
)
},
onClick = onClick,
)
}
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
@Composable
private fun searchSummary(event: EventInstance): String {
val locale = currentLocale()
val zone = remember { ZoneId.systemDefault() }
val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
}
val dateText = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}.format(start)
val use24Hour = LocalUse24HourFormat.current
val timeText = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} else {
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
}
val base = "$dateText · $timeText"
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
}
@Composable
private fun SearchMessage(
icon: ImageVector?,
text: String,
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(16.dp))
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}

View File

@@ -0,0 +1,77 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlin.time.Clock
import javax.inject.Inject
/** Shortest query that triggers a search; one character matches almost everything. */
private const val MIN_QUERY_LENGTH = 2
sealed interface SearchUiState {
/** No query yet, or one too short to search — show the prompt. */
data object Idle : SearchUiState
/** A query ran but matched nothing. */
data class Empty(val query: String) : SearchUiState
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
data class Results(val events: List<EventInstance>) : SearchUiState
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
val state: StateFlow<SearchUiState> = _query
.debounce(250L)
.map { it.trim() }
.distinctUntilChanged()
.mapLatest { q ->
if (q.length < MIN_QUERY_LENGTH) {
SearchUiState.Idle
} else {
val results = repository.searchEvents(q)
if (results.isEmpty()) SearchUiState.Empty(q)
else SearchUiState.Results(sortNearestFirst(results))
}
}
.catch { emit(SearchUiState.Idle) }
.flowOn(io)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
fun setQuery(value: String) {
_query.value = value
}
/** Soonest upcoming (and ongoing) first, then the most recent past. */
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
val now = Clock.System.now()
val (upcoming, past) = events.partition { it.end >= now }
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
}
}

View File

@@ -1,36 +1,78 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import de.jeanlucmakiola.calendula.R
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
/** UI-facing language choice. AUTO follows the system languages. */
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
/**
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
* platform per-app-languages API; below that the appcompat backport persists
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
* it in DataStore. Setting a locale recreates the activity, which re-reads the
* current value for the dropdown.
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
*
* That file is the single source of truth for which languages we ship: dropping
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
* language show up here and in the system per-app-language settings, with no
* other code change. The system-default choice is represented as `null`.
*
* On API 33+ this delegates to the platform per-app-languages API; below that
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
* service), so we don't mirror it in DataStore. Setting a locale recreates the
* activity, which re-reads the current value for the picker.
*/
object AppLanguage {
fun current(): LanguagePref {
val locales = AppCompatDelegate.getApplicationLocales()
if (locales.isEmpty) return LanguagePref.AUTO
return when (locales[0]?.language) {
"de" -> LanguagePref.GERMAN
"en" -> LanguagePref.ENGLISH
else -> LanguagePref.AUTO
/**
* The BCP-47 tags the app ships translations for, in declaration order, as
* listed in locales_config.xml. Returns whatever could be parsed; a missing
* or malformed config yields an empty list (the picker then offers only the
* system-default entry rather than crashing).
*/
fun supportedTags(context: Context): List<String> {
val tags = mutableListOf<String>()
val parser = context.resources.getXml(R.xml.locales_config)
try {
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
}
event = parser.next()
}
} catch (_: Exception) {
// Fall back to whatever was parsed before the failure.
} finally {
parser.close()
}
return tags
}
fun apply(pref: LanguagePref) {
val locales = when (pref) {
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
fun currentTag(): String? {
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
}
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
fun apply(tag: String?) {
val locales = if (tag == null) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(tag)
}
AppCompatDelegate.setApplicationLocales(locales)
}
/**
* The autonym for a tag — the language's own name in its own script, e.g.
* "Deutsch", "English", "Français" — so users find their language regardless
* of the current UI language. Capitalised per the language's own rules.
*/
fun displayName(tag: String): String {
val locale = Locale.forLanguageTag(tag)
return locale.getDisplayName(locale)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
}
}

View File

@@ -1,10 +1,17 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.app.StatusBarManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.annotation.RequiresApi
import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@@ -30,21 +37,29 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
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.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -57,23 +72,53 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalTime
import java.time.format.TextStyle as JavaTextStyle
import java.util.Calendar
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, EventForm, Notifications }
@@ -180,19 +225,83 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
onClick = onManageCalendars,
)
LanguageRow(position = Position.Bottom)
LanguageRow(position = Position.Middle)
// One-tap add of the "New event" Quick Settings tile. The system prompt
// is API 33+; on older versions the tile is still addable manually from
// the QS editor, so the row simply doesn't appear here.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
QuickSettingsTileRow(position = Position.Middle)
}
ReportProblemRow(position = Position.Bottom)
AppVersionText()
}
}
/**
* Opens the project's issue tracker to report a problem. If a crash report was
* captured (and not yet sent), it surfaces that report first via the same
* dialog the next-launch prompt uses; otherwise it opens the issue template
* chooser. No data leaves the device until the user submits the issue.
*/
@Composable
private fun ReportProblemRow(position: Position) {
val context = LocalContext.current
var report by remember { mutableStateOf<String?>(null) }
GroupedRow(
title = stringResource(R.string.settings_report_problem),
summary = stringResource(R.string.settings_report_problem_hint),
position = position,
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
onClick = {
val pending = CrashReporter.pendingReport(context)
if (pending != null) report = pending else openIssueTracker(context)
},
)
report?.let { pending ->
CrashReportDialog(
report = pending,
onSend = {
submitCrashReport(context, pending)
CrashReporter.clearReport(context)
report = null
},
onDismiss = { report = null },
)
}
}
/**
* Asks the system to add the "New event" Quick Settings tile (API 33+). A
* top-level action row, like [ReportProblemRow] — the tile is a system-surface
* shortcut, not part of any one settings category.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
private fun QuickSettingsTileRow(position: Position) {
val context = LocalContext.current
GroupedRow(
title = stringResource(R.string.settings_qs_tile),
summary = stringResource(R.string.settings_qs_tile_hint),
position = position,
leading = { CategoryIcon(Icons.Default.Dashboard, ChipAccent.Neutral) },
onClick = { requestAddQsTile(context) },
)
}
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.current()) }
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
@@ -202,9 +311,9 @@ private fun LanguageRow(position: Position) {
)
if (showDialog) {
OptionPickerDialog(
OptionPicker(
title = stringResource(R.string.settings_language),
options = LanguagePref.entries,
options = options,
selected = current,
label = { languageLabel(it) },
onSelect = {
@@ -212,6 +321,18 @@ private fun LanguageRow(position: Position) {
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
// Invite contributions right where users pick their language.
header = {
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
)
}
}
@@ -221,6 +342,7 @@ private fun AboutCard() {
val context = LocalContext.current
val sourceUrl = stringResource(R.string.about_source_url)
val licenseUrl = stringResource(R.string.about_license_url)
val supportUrl = stringResource(R.string.about_support_url)
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -279,6 +401,19 @@ private fun AboutCard() {
Text(stringResource(R.string.settings_license))
}
}
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { openUrl(context, supportUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_support))
}
}
}
}
@@ -337,11 +472,17 @@ private fun AppearanceScreen(
) {
var showTheme by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showTimeFormat by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
) {
// Theme & colour
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = themeLabel(state.themeMode),
@@ -355,7 +496,7 @@ private fun AppearanceScreen(
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Middle,
position = Position.Bottom,
trailing = {
Switch(
checked = state.dynamicColor,
@@ -369,16 +510,92 @@ private fun AppearanceScreen(
null
},
)
Spacer(Modifier.height(16.dp))
// Calendar — view, week, and timeline formatting
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Top,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_week_start),
summary = weekStartLabel(state.weekStart),
position = Position.Bottom,
position = Position.Middle,
onClick = { showWeekStart = true },
)
GroupedRow(
title = stringResource(R.string.settings_time_format),
summary = timeFormatLabel(state.timeFormat),
position = Position.Middle,
onClick = { showTimeFormat = true },
)
GroupedRow(
title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Middle,
trailing = {
Switch(
checked = state.showHourLines,
onCheckedChange = viewModel::setShowHourLines,
)
},
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
)
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dimCompletedEvents,
onCheckedChange = viewModel::setDimCompletedEvents,
)
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
Spacer(Modifier.height(16.dp))
// Agenda — the only group labelled, so its agenda-specific rows are easy
// to pick out among the otherwise unheadered Appearance settings.
SectionHeader(stringResource(R.string.settings_agenda_header))
GroupedRow(
title = stringResource(R.string.settings_agenda_range),
summary = agendaRangeLabel(state.agendaScreenRange),
position = Position.Top,
onClick = { showAgendaScreenRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = pastEventDisplayLabel(state.pastEventDisplay),
position = Position.Middle,
onClick = { showPastEvents = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange),
position = Position.Middle,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.agendaShowRangeBar,
onCheckedChange = viewModel::setAgendaShowRangeBar,
)
},
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
)
}
if (showTheme) {
OptionPickerDialog(
OptionPicker(
title = stringResource(R.string.settings_theme),
options = ThemeMode.entries,
selected = state.themeMode,
@@ -388,15 +605,63 @@ private fun AppearanceScreen(
)
}
if (showWeekStart) {
OptionPickerDialog(
OptionPicker(
title = stringResource(R.string.settings_week_start),
options = WeekStartPref.entries,
options = WEEK_START_OPTIONS,
selected = state.weekStart,
label = { weekStartLabel(it) },
onSelect = viewModel::setWeekStart,
onDismiss = { showWeekStart = false },
)
}
if (showAgendaScreenRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.settings_agenda_range_hint),
selected = state.agendaScreenRange,
onSelect = viewModel::setAgendaScreenRange,
onDismiss = { showAgendaScreenRange = false },
)
}
if (showAgendaWidgetRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_widget_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = state.agendaWidgetRange,
onSelect = viewModel::setAgendaWidgetRange,
onDismiss = { showAgendaWidgetRange = false },
)
}
if (showTimeFormat) {
OptionPicker(
title = stringResource(R.string.settings_time_format),
options = TimeFormatPref.entries,
selected = state.timeFormat,
label = { timeFormatLabel(it) },
onSelect = viewModel::setTimeFormat,
onDismiss = { showTimeFormat = false },
)
}
if (showPastEvents) {
OptionPicker(
title = stringResource(R.string.settings_past_events),
options = PastEventDisplay.entries,
selected = state.pastEventDisplay,
label = { pastEventDisplayLabel(it) },
onSelect = viewModel::setPastEventDisplay,
onDismiss = { showPastEvents = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
onSelect = viewModel::setDefaultView,
onDismiss = { showDefaultView = false },
)
}
}
@Composable
@@ -420,8 +685,17 @@ private fun EventFormScreen(
fields.forEachIndexed { index, field ->
val checked = field in state.defaultFormFields
GroupedRow(
title = stringResource(formFieldLabel(field)),
title = stringResource(eventFormFieldLabel(field)),
position = positionOf(index, fields.size),
// Same icon the field carries in the new-event form, so a toggle
// is easy to match to the field it controls.
leading = {
Icon(
imageVector = eventFormFieldIcon(field),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(
checked = checked,
@@ -431,9 +705,70 @@ private fun EventFormScreen(
onClick = { viewModel.setFormFieldDefault(field, !checked) },
)
}
// Auto-focus the title on a new event (issue #10) — on by default, since
// most events get a title; raising the keyboard saves a tap. Off lets you
// set the time/calendar first without the keyboard in the way.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_autofocus_title),
summary = stringResource(R.string.settings_autofocus_title_hint),
position = Position.Alone,
leading = {
Icon(
imageVector = Icons.Default.Keyboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(
checked = state.autofocusEventTitle,
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
)
},
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
)
// Per-event colour on calendars that publish no colour set (some
// CalDAV) — off by default, with the honest caveat that the colour may
// not survive their next sync. Local and palette calendars ignore it.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_color_unsupported),
summary = stringResource(R.string.settings_color_unsupported_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.allowColorOnUnsupportedCalendars,
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
)
},
onClick = {
viewModel.setAllowColorOnUnsupportedCalendars(
!state.allowColorOnUnsupportedCalendars,
)
},
)
}
}
/**
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
* shows its own confirmation dialog and handles the already-added case, so no
* result handling is needed here.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun requestAddQsTile(context: Context) {
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
statusBar.requestAddTileService(
ComponentName(context, NewEventTileService::class.java),
context.getString(R.string.qs_tile_new_event_label),
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
@@ -461,6 +796,14 @@ private fun NotificationsScreen(
}
}
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
onBack = onBack,
@@ -468,13 +811,325 @@ private fun NotificationsScreen(
GroupedRow(
title = stringResource(R.string.settings_reminders),
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Alone,
position = Position.Top,
trailing = {
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
},
onClick = { toggleReminders(!state.remindersEnabled) },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.defaultReminderMinutes),
position = Position.Middle,
onClick = { showDefaultReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
position = Position.Middle,
onClick = { showAllDayReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_allday_reminder_time),
summary = stringResource(
R.string.settings_allday_reminder_time_hint,
formatTimeOfDay(context, state.allDayReminderTimeMinutes),
),
position = Position.Bottom,
onClick = { showAllDayReminderTime = true },
)
// Delivery reliability + snooze: both are global reminder-delivery
// settings, so they sit with the defaults above rather than below the
// long per-calendar list. Reliability is a soft, optional battery-
// optimisation exemption (system-settings deep-link, no special
// permission); shown as live status, reversible at any time.
Spacer(Modifier.height(24.dp))
val batteryExempt = rememberBatteryOptimizationExempt()
GroupedRow(
title = stringResource(R.string.settings_reliable_delivery),
summary = if (batteryExempt) {
stringResource(R.string.settings_reliable_delivery_exempt)
} else {
stringResource(R.string.settings_reliable_delivery_hint)
},
position = Position.Top,
trailing = if (batteryExempt) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { openBatteryOptimizationSettings(context) },
)
// Snooze: how long the notification's "Snooze" action defers a reminder.
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
position = Position.Bottom,
onClick = { showSnooze = true },
)
// Per-calendar overrides: the whole section folds behind one header to
// keep the screen tidy. Expanded, each writable calendar gets its own
// expandable card that may keep, drop, or replace the global default —
// separately for timed and all-day events.
if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_calendar_reminders_title),
summary = stringResource(R.string.settings_calendar_reminders_hint),
position = Position.Alone,
trailing = {
Icon(
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
)
AnimatedVisibility(
visible = calendarSectionExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column {
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list
// of three (the card + the timed and all-day rows).
GroupedRow(
title = calendar.displayName,
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = {
expandedCalendars = if (expanded) {
expandedCalendars - calendar.id
} else {
expandedCalendars + calendar.id
}
},
)
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column {
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
)
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
)
}
}
}
}
}
}
}
if (showSnooze) {
OptionPicker(
title = stringResource(R.string.settings_snooze_duration),
options = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) },
onDismiss = { showSnooze = false },
)
}
if (showDefaultReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
onDismiss = { showDefaultReminder = false },
)
}
if (showAllDayReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder_allday),
presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
onDismiss = { showAllDayReminder = false },
)
}
if (showAllDayReminderTime) {
TimePickerAlert(
initial = LocalTime(
state.allDayReminderTimeMinutes / 60,
state.allDayReminderTimeMinutes % 60,
),
onConfirm = {
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
showAllDayReminderTime = false
},
onDismiss = { showAllDayReminderTime = false },
)
}
overrideDialog?.let { target ->
val map = if (target.isAllDay) {
state.perCalendarAllDayReminderOverride
} else {
state.perCalendarReminderOverride
}
ReminderDefaultPicker(
title = stringResource(
if (target.isAllDay) {
R.string.settings_default_reminder_allday
} else {
R.string.settings_default_reminder
},
),
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
selected = map.choiceFor(target.calendarId),
allowInherit = true,
onSelect = {
if (target.isAllDay) {
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
} else {
viewModel.setCalendarReminderOverride(target.calendarId, it)
}
},
onDismiss = { overrideDialog = null },
)
}
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (null = none) as a picker choice for selection highlighting. */
private fun Int?.toReminderChoice(): CalendarReminderOverride =
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
(this as? CalendarReminderOverride.Minutes)?.minutes
/**
* Whether Calendula is exempt from battery optimisation, re-read on every
* `ON_RESUME` so the row reflects a change the user just made in system
* settings without needing to leave and re-enter the screen.
*/
@Composable
private fun rememberBatteryOptimizationExempt(): Boolean {
val context = LocalContext.current
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
exempt = isIgnoringBatteryOptimizations(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
return exempt
}
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return power.isIgnoringBatteryOptimizations(context.packageName)
}
/**
* Take the user straight to Calendula's exemption: the direct
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
* battery optimisation?") rather than the full app list they'd have to scroll.
* Falls back to the optimisation list if the OS refuses the direct intent.
*/
private fun openBatteryOptimizationSettings(context: Context) {
val direct = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${context.packageName}".toUri(),
)
if (runCatching { context.startActivity(direct) }.isFailure) {
runCatching {
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
}
}
/**
* Lead times offered for the all-day default — day-scale, since a "minutes
* before midnight" reminder on an all-day event is rarely what's wanted.
*/
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
@Composable
private fun snoozeDurationLabel(minutes: Int): String =
if (minutes % 60 == 0) {
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
} else {
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
}
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
val time = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, minutesOfDay / 60)
set(Calendar.MINUTE, minutesOfDay % 60)
}.time
return DateFormat.getTimeFormat(context).format(time)
}
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
this[calendarId] == null -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
}
/** Label for a global-default choice: null → "None", else the lead time. */
@Composable
private fun reminderChoiceLabel(minutes: Int?): String =
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
/** Row summary for a calendar: its override, or the inherited global default. */
@Composable
private fun calendarOverrideSummary(
choice: CalendarReminderOverride,
globalDefault: Int?,
): String = when (choice) {
CalendarReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
}
// ---------------------------------------------------------------------------
@@ -510,53 +1165,12 @@ private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
}
}
/** OptionCard selection dialog — the app's only sanctioned picker style. */
@Composable
private fun <T> OptionPickerDialog(
title: String,
options: List<T>,
selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { option ->
OptionCard(
label = label(option),
onClick = {
onSelect(option)
onDismiss()
},
selected = option == selected,
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
runCatching { context.startActivity(intent) }
}
private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
}
@Composable
private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) {
@@ -566,20 +1180,48 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
},
)
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
private val WEEK_START_OPTIONS: List<WeekStartPref> =
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
@Composable
private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
private fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
// Localised full weekday name, so any of the seven days reads naturally
// without a per-day string resource.
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, currentLocale())
}
@Composable
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
when (pref) {
WeekStartPref.AUTO -> R.string.settings_week_start_auto
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
},
)
/** A small primary-coloured group label, matching the Calendars settings screen. */
@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 pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource(
when (mode) {
PastEventDisplay.SHOW -> R.string.settings_past_events_show
PastEventDisplay.DIM -> R.string.settings_past_events_dim
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
},
)
@Composable
private fun languageLabel(pref: LanguagePref): String = stringResource(
when (pref) {
LanguagePref.AUTO -> R.string.settings_language_auto
LanguagePref.GERMAN -> R.string.settings_language_german
LanguagePref.ENGLISH -> R.string.settings_language_english
},
)
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)

View File

@@ -1,9 +1,14 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -15,9 +20,53 @@ data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO,
val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourLines: Boolean = false,
/** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
val dimCompletedEvents: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
val autofocusEventTitle: Boolean = true,
/** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true,
/**
* The default reminder lead time (minutes) prefilled on new timed events;
* null = no default reminder. Per-calendar overrides take precedence.
*/
val defaultReminderMinutes: Int? = null,
/** The default reminder lead time prefilled on new all-day events; null = none. */
val defaultAllDayReminderMinutes: Int? = null,
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
/**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
* calendar present in the map overrides the global default (null value = no
* reminder); absent = inherit the global default.
*/
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/**
* Whether the event-colour picker is offered on calendars that publish no
* colour palette (the colour may then not survive their next sync).
*/
val allowColorOnUnsupportedCalendars: Boolean = false,
)

View File

@@ -1,42 +1,129 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
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.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
repository: CalendarRepository,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/** Writable calendars — the only ones that take a per-calendar reminder override. */
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(
prefs.themeMode,
prefs.dynamicColor,
prefs.weekStart,
prefs.defaultFormFields,
prefs.remindersEnabled,
) { theme, dynamic, weekStart, formFields, reminders ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
defaultFormFields = formFields,
remindersEnabled = reminders,
// combine() types up to five flows, so the prefs split into two
// groups that fold together in the outer combine.
combine(
prefs.themeMode,
prefs.dynamicColor,
prefs.weekStart,
prefs.defaultFormFields,
prefs.remindersEnabled,
) { theme, dynamic, weekStart, formFields, reminders ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
defaultFormFields = formFields,
remindersEnabled = reminders,
)
},
combine(
prefs.allowColorOnUnsupportedCalendars,
prefs.defaultReminderMinutes,
prefs.defaultAllDayReminderMinutes,
prefs.allDayReminderTimeMinutes,
prefs.snoozeMinutes,
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
ReminderDefaults(
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
)
},
combine(
prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
combine(
prefs.defaultView,
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
prefs.showHourLines,
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
},
combine(
prefs.agendaShowRangeBar,
prefs.autofocusEventTitle,
prefs.pastEventDisplay,
prefs.dimCompletedEvents,
::MiscSettings,
),
) { base, defaults, overrides, views, misc ->
base.copy(
defaultView = views.defaultView,
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
dimCompletedEvents = misc.dimCompletedEvents,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
allDayReminderTimeMinutes = defaults.allDayReminderTime,
snoozeMinutes = defaults.snoozeMinutes,
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
)
}.stateIn(
scope = viewModelScope,
@@ -44,6 +131,41 @@ class SettingsViewModel @Inject constructor(
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
// updateAll calls at once — concurrent calls coalesce around a stale read
// and can leave the widget on the intermediate value. Held sequentially,
// the last call reads the final committed pref and renders it.
private val widgetRefreshMutex = Mutex()
private data class ReminderDefaults(
val allowColor: Boolean,
val defaultReminder: Int?,
val allDayReminder: Int?,
val allDayReminderTime: Int,
val snoozeMinutes: Int,
)
private data class ReminderOverrides(
val timed: Map<Long, Int?>,
val allDay: Map<Long, Int?>,
val calendars: List<CalendarSource>,
)
private data class ViewSettings(
val defaultView: CalendarView,
val agendaScreenRange: AgendaRange,
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
)
private data class MiscSettings(
val showRangeBar: Boolean,
val autofocusEventTitle: Boolean,
val pastEventDisplay: PastEventDisplay,
val dimCompletedEvents: Boolean,
)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
@@ -53,7 +175,72 @@ class SettingsViewModel @Inject constructor(
}
fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch { prefs.setWeekStart(pref) }
viewModelScope.launch {
prefs.setWeekStart(pref)
// Both widgets depend on the week start (month weekday header; the
// agenda widget's "this week" range).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setAgendaScreenRange(range: AgendaRange) {
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
}
fun setAgendaWidgetRange(range: AgendaRange) {
viewModelScope.launch {
prefs.setAgendaWidgetRange(range)
widgetRefreshMutex.withLock {
// Push the range into each instance's Glance state and recompose.
// The widget reads it via currentState, so this reflects reliably
// even when updateAll only recomposes a live session (it does not
// re-run provideGlance's data preamble).
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowRangeBar(enabled: Boolean) {
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
fun setTimeFormat(pref: TimeFormatPref) {
viewModelScope.launch { prefs.setTimeFormat(pref) }
}
fun setShowHourLines(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourLines(enabled) }
}
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)
// The agenda widget honours this setting too, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setAgendaWidgetRange (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_PAST_DISPLAY_KEY] = mode.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setDimCompletedEvents(enabled: Boolean) {
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
}
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
}
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
@@ -63,4 +250,36 @@ class SettingsViewModel @Inject constructor(
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
}
fun setAutofocusEventTitle(enabled: Boolean) {
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
}
fun setDefaultReminderMinutes(minutes: Int?) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
}
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
}
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
}
fun setSnoozeMinutes(minutes: Int) {
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
}
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
}
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
}
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
}
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -44,7 +45,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@@ -54,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
@@ -72,13 +76,25 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
@@ -113,12 +129,21 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
val nowState = rememberCurrentMinute()
val dimCutoff by remember(dimCompleted) {
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -156,6 +181,11 @@ fun WeekScreen(
}
viewModel.goToToday()
}
// Drawer jump-to-date: slide from the side the target week lies on.
val jumpToDate: (LocalDate) -> Unit = { target ->
slideDir = if (target < weekStart) -1 else 1
viewModel.goToDate(target)
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -164,10 +194,15 @@ fun WeekScreen(
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = weekStart,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
@@ -183,6 +218,7 @@ fun WeekScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -200,19 +236,21 @@ fun WeekScreen(
)
},
) { innerPadding ->
WeekContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
WeekContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
}
@@ -233,6 +271,8 @@ private fun WeekContent(
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
@@ -286,7 +326,7 @@ private fun WeekContent(
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "week-transition",
) { s ->
when (s) {
@@ -341,6 +381,7 @@ private fun WeekTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -359,6 +400,12 @@ private fun WeekTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -520,8 +567,10 @@ private fun AllDayBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
Box(
modifier = modifier
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
@@ -547,6 +596,8 @@ private fun Timeline(
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day columns are two scroll viewports that SHARE one scroll
@@ -570,7 +621,7 @@ private fun Timeline(
) {
if (h > 0) {
Text(
text = "%02d".format(h),
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
@@ -600,6 +651,7 @@ private fun Timeline(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -618,11 +670,14 @@ private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -635,6 +690,9 @@ private fun DayColumnCard(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there; taps on event
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
@@ -653,6 +711,7 @@ private fun DayColumnCard(
EventBlock(
block = block,
dark = dark,
height = height,
onClick = { onEventClick(block.event) },
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
@@ -661,6 +720,10 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
}
}
}
}
@@ -669,14 +732,35 @@ private fun DayColumnCard(
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}"
val showTime = block.endMin - block.startMin >= 45
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
// Only full-width (non-overlapping) blocks that are tall enough show the time.
// On narrow overlapping columns we drop it so the title can wrap to fill the
// whole block, mirroring Google Calendar.
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// Wrap the title across as many lines as the block can fit (minus the 2.dp
// top/bottom padding and the reserved time line) instead of clipping it to a
// single character on slim, overlapping blocks.
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
Box(
modifier = modifier
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
@@ -686,18 +770,15 @@ private fun EventBlock(
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
)
if (showTime) {
// Narrow columns can't fit "13:0014:00" on one line, so let it
// wrap to a second line (after the dash) instead of clipping the
// end time.
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
)
@@ -742,8 +823,8 @@ private fun WeekLoading() {
}
}
private fun minToHm(min: Int): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatWeekRange(weekStart: LocalDate): String {
val locale = Locale.getDefault()

View File

@@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor(
private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault()
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
@@ -107,6 +115,11 @@ class WeekViewModel @Inject constructor(
_anchor.value = todayDate
}
/** Jump to the week containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_anchor.value = date
}
private fun buildState(
start: LocalDate,
calendars: List<CalendarSource>,
@@ -176,6 +189,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
if (isAllDay) {
// All-day events live at UTC midnights with an exclusive end. Compare
// calendar dates in UTC and step the exclusive end back to the last
// covered day (mirroring the detail/edit views), so a one-day event
// covers exactly its single date. Slicing the day in the device zone
// would push the exclusive end a few hours into the next local day
// east of UTC, making the event leak onto day + 1.
val startDate = start.toLocalDateTime(TimeZone.UTC).date
val endExclusive = end.toLocalDateTime(TimeZone.UTC).date
val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY))
return day in startDate..lastDay
}
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return start < dayEnd && end > dayStart

View File

@@ -0,0 +1,153 @@
package de.jeanlucmakiola.calendula.widget
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
/**
* How far either side of today the month widget pre-loads. The displayed month
* is chosen reactively in the composition, so one wide read covers ~13 months of
* prev/next navigation without re-querying on every arrow tap.
*/
private const val MONTH_WIDGET_RANGE_DAYS = 400
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
internal fun today(zone: TimeZone): LocalDate =
Clock.System.now().toLocalDateTime(zone).date
internal fun Context.hasCalendarPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
/** Snapshot rendered by the agenda widget. */
sealed interface AgendaWidgetData {
/** Calendar permission not granted — the widget can't read events. */
data object NeedsPermission : AgendaWidgetData
data class Ready(
val today: LocalDate,
/**
* Days with events across the **widest** selectable window. The displayed
* range is sliced from this in the composition (read reactively from
* Glance state), so a range change is pure recomposition rather than a
* flaky widget session restart — the same approach the month widget uses.
* Kept unfiltered: hiding/dimming finished events also happens reactively
* in the composition, against [now], for the same reliable-update reason.
*/
val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
}
/**
* Source data for the month widget: a wide window of instances plus the
* week-start preference and today. The widget computes each displayed month's
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
* so month navigation is pure recomposition — no reload, no flaky widget
* session restart.
*/
sealed interface MonthWidgetSource {
data object NeedsPermission : MonthWidgetSource
data class Ready(
val today: LocalDate,
val weekStart: DayOfWeek,
val instances: List<EventInstance>,
) : MonthWidgetSource
}
/**
* Process-lived cache of the wide month window. Month navigation re-runs
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
* every arrow tap is what made paging feel sluggish — so we load once and reuse
* the same snapshot for every nearby month. Invalidated by
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
* receiver), and automatically when the day rolls over (the `today` guard).
*/
internal object MonthWidgetCache {
@Volatile
var data: MonthWidgetSource.Ready? = null
}
internal fun invalidateMonthWidgetCache() {
MonthWidgetCache.data = null
}
/**
* One-shot read of the upcoming agenda for the widget. Reuses the app's
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [first]-emitted snapshot already has hidden calendars filtered out.
*/
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone()
val anchor = today(zone)
val ep = widgetEntryPoint()
val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, instances, zone),
is24Hour = is24Hour,
weekStart = weekStart,
savedRange = savedRange,
savedPastDisplay = savedPastDisplay,
now = Clock.System.now(),
)
}
/** One-shot wide read backing the month widget's grid for any nearby month. */
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone()
val anchor = today(zone)
// Reuse the cached window unless the day changed (then it's stale for "today").
MonthWidgetCache.data?.let { if (it.today == anchor) return it }
val ep = widgetEntryPoint()
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
val instances = ep.calendarRepository().instances(from..to).first()
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
.also { MonthWidgetCache.data = it }
}

View File

@@ -0,0 +1,27 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
/**
* Hilt bridge for the Glance widgets. A [androidx.glance.appwidget.GlanceAppWidget]
* is instantiated by the framework, not by Hilt, so it can't take constructor
* injection. We instead reach the singleton graph through this entry point and
* read the same [CalendarRepository] / [SettingsPrefs] the app uses — so widget
* data (hidden-calendar filtering, week-start preference, …) matches the app
* one-to-one.
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun calendarRepository(): CalendarRepository
fun settingsPrefs(): SettingsPrefs
}
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
EntryPointAccessors.fromApplication(applicationContext, WidgetEntryPoint::class.java)

View File

@@ -0,0 +1,36 @@
package de.jeanlucmakiola.calendula.widget
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.material3.ColorProviders
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
/**
* Brand fallback for devices without Material You dynamic colour (API < 31).
* Reuses the exact same hand-tuned schemes as the in-app theme
* ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
* device matches the app surface-for-surface.
*/
private val CalendulaGlanceColors = ColorProviders(
light = CalendulaLightFallback,
dark = CalendulaDarkFallback,
)
/**
* Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
* Material You palette (so the widget matches the home screen / the app's
* dynamic colour); below that it falls back to the brand scheme. Either way the
* widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
* a hardcoded colour — so it tracks light/dark automatically.
*/
@Composable
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GlanceTheme.colors
} else {
CalendulaGlanceColors
}
GlanceTheme(colors = colors, content = content)
}

View File

@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.widget
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.updateAll
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Redraws both home-screen widgets when their data goes stale. Triggered by:
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
* so it covers both the app's own writes and external sync.
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
* and the upcoming window roll over at midnight / on a clock change.
*
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
* XML, and the month widget's refresh button forces an immediate redraw.
*/
class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
val appContext = context.applicationContext
// Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant.
invalidateMonthWidgetCache()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
} finally {
pending.finish()
}
}
}
}

View File

@@ -0,0 +1,353 @@
package de.jeanlucmakiola.calendula.widget.agenda
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
* of events grouped under day headers (the Google "Schedule" widget model).
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/
/**
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
* The range is read reactively in the composition ([currentState]) so a settings
* change is reflected by plain recomposition — `updateAll` does NOT reliably
* re-run the `provideGlance` preamble for a live widget session, so loading the
* range there and slicing it would silently keep the stale value (mirrors the
* month widget's MONTH_INDEX_KEY approach).
*/
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
/**
* Per-instance Glance state key holding the past-event display mode (as
* [PastEventDisplay.name]). 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_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData()
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent {
CalendulaGlanceTheme {
AgendaWidgetBody(data = data, dark = dark)
}
}
}
}
/** Re-reads the calendar and redraws the widget (header refresh button). */
class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
AgendaWidget().updateAll(context.applicationContext)
}
}
/** Flat row model so the [LazyColumn] can mix day headers and events. */
private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(val event: EventInstance) : AgendaRow
}
@Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
AgendaHeader()
Spacer(GlanceModifier.height(4.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
is AgendaWidgetData.Ready -> {
// Range read reactively from per-instance Glance state (falls back
// to the saved pref for a freshly placed widget), then the wide
// pre-loaded window is sliced to it — pure recomposition.
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.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.
val visibleDays = data.days
.filter { it.date <= rangeEnd }
.let { days ->
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
}
}
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
} else {
val rows = buildList {
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
day.events.forEach { add(AgendaRow.Event(it)) }
}
}
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Event -> EventRow(
event = row.event,
dark = dark,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
)
}
}
}
}
}
}
}
}
@Composable
private fun AgendaHeader() {
val context = androidx.glance.LocalContext.current
Row(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = context.getString(R.string.widget_agenda_title),
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier.defaultWeight(),
)
IconButton(
resId = R.drawable.ic_widget_refresh,
contentDescription = context.getString(R.string.widget_refresh),
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
)
IconButton(
resId = R.drawable.ic_widget_add,
contentDescription = context.getString(R.string.widget_new_event),
onClick = GlanceModifier.clickable(
actionStartActivity(
MainActivity.openCreateIntent(context, today(systemZone())),
),
),
)
}
}
@Composable
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
Box(
modifier = GlanceModifier.size(40.dp).then(onClick),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(22.dp),
)
}
}
@Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
val context = androidx.glance.LocalContext.current
Text(
text = agendaDayLabel(context, date, today),
style = TextStyle(
color = if (date == today) GlanceTheme.colors.primary
else GlanceTheme.colors.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
@Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
val context = androidx.glance.LocalContext.current
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
// dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = pastelize(event.color, dark).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
),
),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(5.dp)
.height(36.dp)
.cornerRadius(3.dp)
.background(stripeColor),
) {}
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = title,
maxLines = 1,
style = TextStyle(color = titleColor, fontSize = 14.sp),
)
Text(
text = eventTimeSummary(context, event, is24Hour),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
}
}
}
@Composable
private fun WidgetMessage(resId: Int) {
val context = androidx.glance.LocalContext.current
Box(
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = context.getString(resId),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
)
}
}
private fun zone(): TimeZone = systemZone()
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val relative = when (date) {
today -> context.getString(R.string.agenda_header_today)
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
else -> null
}
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
val formatted = "$weekday, ${date.day} $monthName"
return if (relative != null) "$relative · $formatted" else formatted
}
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
val time = if (event.isAllDay) {
context.getString(R.string.event_detail_all_day)
} else {
"${formatTime(event.start, is24Hour)} ${formatTime(event.end, is24Hour)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(zone()).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
}

Some files were not shown because too many files have changed in this diff Show More